From ce003f96ba5a649153df3ccdfabeb16d960a0428 Mon Sep 17 00:00:00 2001 From: Mat Ryer Date: Sun, 6 May 2018 17:36:20 +0100 Subject: [PATCH] initial commit --- Makefile | 6 +++ README.md | 15 +++++- main.go | 134 ++++++++++++++++++++++++++++++++++++++++++++++++ main_test.go | 57 ++++++++++++++++++++ testdata/app.go | 7 +++ 5 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 Makefile create mode 100644 main.go create mode 100644 main_test.go create mode 100644 testdata/app.go diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..773ccc5 --- /dev/null +++ b/Makefile @@ -0,0 +1,6 @@ +test: + go build -o testdata/app testdata/app.go + go build -o appify + go test + rm appify + rm testdata/app diff --git a/README.md b/README.md index 486551b..f2cfc80 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,15 @@ # appify -Create a macOS Application from a Go binary + +Create a macOS Application from an executable (like a Go binary) + +## Install + +To install `appify`: + +```bash +go get github.com/machinebox/appify +``` + +## Usage + +appify -name "My Go Application" /path/to/bin diff --git a/main.go b/main.go new file mode 100644 index 0000000..48522b3 --- /dev/null +++ b/main.go @@ -0,0 +1,134 @@ +package main + +import ( + "flag" + "fmt" + "io" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "text/template" + + "github.com/pkg/errors" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "%s", err) + os.Exit(2) + } +} + +func run() error { + var ( + name = flag.String("name", "My Go Application", "app name") + author = flag.String("author", "Appify by Machine Box", "author") + version = flag.String("version", "1.0", "app version") + identifier = flag.String("id", "", "bundle identifier") + ) + flag.Parse() + args := flag.Args() + if len(args) < 1 { + return errors.New("missing executable argument") + } + bin := args[0] + appname := *name + ".app" + contentspath := filepath.Join(appname, "Contents") + apppath := filepath.Join(contentspath, "MacOS") + binpath := filepath.Join(apppath, appname) + if err := os.MkdirAll(apppath, 0777); err != nil { + return errors.Wrap(err, "os.MkdirAll") + } + fdst, err := os.Create(binpath) + if err != nil { + return errors.Wrap(err, "create bin") + } + defer fdst.Close() + fsrc, err := os.Open(bin) + if err != nil { + if os.IsNotExist(err) { + return errors.New(bin + " not found") + } + return errors.Wrap(err, "os.Open") + } + defer fsrc.Close() + if _, err := io.Copy(fdst, fsrc); err != nil { + return errors.Wrap(err, "copy bin") + } + if err := exec.Command("chmod", "+x", apppath).Run(); err != nil { + return errors.Wrap(err, "chmod: "+apppath) + } + if err := exec.Command("chmod", "+x", binpath).Run(); err != nil { + return errors.Wrap(err, "chmod: "+binpath) + } + id := *identifier + if id == "" { + id = *author + "." + *name + } + info := infoListData{ + Name: *name, + Executable: filepath.Join("MacOS", appname), + Identifier: id, + Version: *version, + InfoString: *name + " by " + *author, + ShortVersionString: *version, + } + tpl, err := template.New("template").Parse(infoPlistTemplate) + if err != nil { + return errors.Wrap(err, "infoPlistTemplate") + } + fplist, err := os.Create(filepath.Join(contentspath, "Info.plist")) + if err != nil { + return errors.Wrap(err, "create Info.plist") + } + defer fplist.Close() + if err := tpl.Execute(fplist, info); err != nil { + return errors.Wrap(err, "execute Info.plist template") + } + if err := ioutil.WriteFile(filepath.Join(contentspath, "README"), []byte(readme), 0666); err != nil { + return errors.Wrap(err, "ioutil.WriteFile") + } + return nil +} + +type infoListData struct { + Name string + Executable string + Identifier string + Version string + InfoString string + ShortVersionString string +} + +const infoPlistTemplate = ` + + + + CFBundlePackageType + APPL + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + {{ .Name }} + CFBundleExecutable + {{ .Executable }} + CFBundleIdentifier + {{ .Identifier }} + CFBundleVersion + {{ .Version }} + CFBundleGetInfoString + {{ .InfoString }} + CFBundleShortVersionString + {{ .ShortVersionString }} + + +` + +// readme goes into a README file inside the package for +// future reference. +const readme = `Made with Appify by Machine Box +https://github.com/machinebox/appify + +Inspired by https://gist.github.com/anmoljagetia/d37da67b9d408b35ac753ce51e420132 +` diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..aceb348 --- /dev/null +++ b/main_test.go @@ -0,0 +1,57 @@ +package main + +import ( + "crypto/md5" + "fmt" + "io" + "os" + "os/exec" + "testing" + + "github.com/matryer/is" +) + +func Test(t *testing.T) { + is := is.New(t) + out, err := exec.Command("./appify", "-name", "Test", "testdata/app").CombinedOutput() + t.Logf("%q", string(out)) + is.NoErr(err) + defer os.RemoveAll("Test.app") + actualAppHash := filehash(t, "testdata/app") + type file struct { + path string + perm string + hash string + } + for _, f := range []file{ + {path: "Test.app", perm: "drwxr-xr-x"}, + {path: "Test.app/Contents", perm: "drwxr-xr-x"}, + {path: "Test.app/Contents/MacOS", perm: "drwxr-xr-x"}, + {path: "Test.app/Contents/MacOS/Test.app", perm: "-rwxr-xr-x", hash: actualAppHash}, + {path: "Test.app/Contents/Info.plist", perm: "-rw-r--r--", hash: "d263b0111cec1e6677970a35cc52f14d"}, + {path: "Test.app/Contents/README", perm: "-rw-r--r--", hash: "afeb10df47c7f189b848ae44a54e7e06"}, + } { + t.Run(f.path, func(t *testing.T) { + is := is.New(t) + info, err := os.Stat(f.path) + is.NoErr(err) + is.Equal(info.Mode().String(), f.perm) + if f.hash != "" { + actual := filehash(t, f.path) + is.Equal(actual, f.hash) // hash + } + }) + } +} + +// filehash gets an md5 hash of the file at path. +func filehash(t *testing.T, path string) string { + is := is.New(t) + f, err := os.Open(path) + is.NoErr(err) + defer f.Close() + h := md5.New() + _, err = io.Copy(h, f) + is.NoErr(err) + return fmt.Sprintf("%x", h.Sum(nil)) +} diff --git a/testdata/app.go b/testdata/app.go new file mode 100644 index 0000000..cedd44e --- /dev/null +++ b/testdata/app.go @@ -0,0 +1,7 @@ +package main + +import "log" + +func main() { + log.Println("Hello world of desktop applications") +}