Skip to content

Commit ce003f9

Browse files
committed
initial commit
1 parent 45ad66e commit ce003f9

File tree

5 files changed

+218
-1
lines changed

5 files changed

+218
-1
lines changed

Makefile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
test:
2+
go build -o testdata/app testdata/app.go
3+
go build -o appify
4+
go test
5+
rm appify
6+
rm testdata/app

README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,15 @@
11
# appify
2-
Create a macOS Application from a Go binary
2+
3+
Create a macOS Application from an executable (like a Go binary)
4+
5+
## Install
6+
7+
To install `appify`:
8+
9+
```bash
10+
go get github.com/machinebox/appify
11+
```
12+
13+
## Usage
14+
15+
appify -name "My Go Application" /path/to/bin

main.go

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
package main
2+
3+
import (
4+
"flag"
5+
"fmt"
6+
"io"
7+
"io/ioutil"
8+
"os"
9+
"os/exec"
10+
"path/filepath"
11+
"text/template"
12+
13+
"github.com/pkg/errors"
14+
)
15+
16+
func main() {
17+
if err := run(); err != nil {
18+
fmt.Fprintf(os.Stderr, "%s", err)
19+
os.Exit(2)
20+
}
21+
}
22+
23+
func run() error {
24+
var (
25+
name = flag.String("name", "My Go Application", "app name")
26+
author = flag.String("author", "Appify by Machine Box", "author")
27+
version = flag.String("version", "1.0", "app version")
28+
identifier = flag.String("id", "", "bundle identifier")
29+
)
30+
flag.Parse()
31+
args := flag.Args()
32+
if len(args) < 1 {
33+
return errors.New("missing executable argument")
34+
}
35+
bin := args[0]
36+
appname := *name + ".app"
37+
contentspath := filepath.Join(appname, "Contents")
38+
apppath := filepath.Join(contentspath, "MacOS")
39+
binpath := filepath.Join(apppath, appname)
40+
if err := os.MkdirAll(apppath, 0777); err != nil {
41+
return errors.Wrap(err, "os.MkdirAll")
42+
}
43+
fdst, err := os.Create(binpath)
44+
if err != nil {
45+
return errors.Wrap(err, "create bin")
46+
}
47+
defer fdst.Close()
48+
fsrc, err := os.Open(bin)
49+
if err != nil {
50+
if os.IsNotExist(err) {
51+
return errors.New(bin + " not found")
52+
}
53+
return errors.Wrap(err, "os.Open")
54+
}
55+
defer fsrc.Close()
56+
if _, err := io.Copy(fdst, fsrc); err != nil {
57+
return errors.Wrap(err, "copy bin")
58+
}
59+
if err := exec.Command("chmod", "+x", apppath).Run(); err != nil {
60+
return errors.Wrap(err, "chmod: "+apppath)
61+
}
62+
if err := exec.Command("chmod", "+x", binpath).Run(); err != nil {
63+
return errors.Wrap(err, "chmod: "+binpath)
64+
}
65+
id := *identifier
66+
if id == "" {
67+
id = *author + "." + *name
68+
}
69+
info := infoListData{
70+
Name: *name,
71+
Executable: filepath.Join("MacOS", appname),
72+
Identifier: id,
73+
Version: *version,
74+
InfoString: *name + " by " + *author,
75+
ShortVersionString: *version,
76+
}
77+
tpl, err := template.New("template").Parse(infoPlistTemplate)
78+
if err != nil {
79+
return errors.Wrap(err, "infoPlistTemplate")
80+
}
81+
fplist, err := os.Create(filepath.Join(contentspath, "Info.plist"))
82+
if err != nil {
83+
return errors.Wrap(err, "create Info.plist")
84+
}
85+
defer fplist.Close()
86+
if err := tpl.Execute(fplist, info); err != nil {
87+
return errors.Wrap(err, "execute Info.plist template")
88+
}
89+
if err := ioutil.WriteFile(filepath.Join(contentspath, "README"), []byte(readme), 0666); err != nil {
90+
return errors.Wrap(err, "ioutil.WriteFile")
91+
}
92+
return nil
93+
}
94+
95+
type infoListData struct {
96+
Name string
97+
Executable string
98+
Identifier string
99+
Version string
100+
InfoString string
101+
ShortVersionString string
102+
}
103+
104+
const infoPlistTemplate = `<?xml version="1.0" encoding="UTF-8"?>
105+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
106+
<plist version="1.0">
107+
<dict>
108+
<key>CFBundlePackageType</key>
109+
<string>APPL</string>
110+
<key>CFBundleInfoDictionaryVersion</key>
111+
<string>6.0</string>
112+
<key>CFBundleName</key>
113+
<string>{{ .Name }}</string>
114+
<key>CFBundleExecutable</key>
115+
<string>{{ .Executable }}</string>
116+
<key>CFBundleIdentifier</key>
117+
<string>{{ .Identifier }}</string>
118+
<key>CFBundleVersion</key>
119+
<string>{{ .Version }}</string>
120+
<key>CFBundleGetInfoString</key>
121+
<string>{{ .InfoString }}</string>
122+
<key>CFBundleShortVersionString</key>
123+
<string>{{ .ShortVersionString }}</string>
124+
</dict>
125+
</plist>
126+
`
127+
128+
// readme goes into a README file inside the package for
129+
// future reference.
130+
const readme = `Made with Appify by Machine Box
131+
https://github.com/machinebox/appify
132+
133+
Inspired by https://gist.github.com/anmoljagetia/d37da67b9d408b35ac753ce51e420132
134+
`

main_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package main
2+
3+
import (
4+
"crypto/md5"
5+
"fmt"
6+
"io"
7+
"os"
8+
"os/exec"
9+
"testing"
10+
11+
"github.com/matryer/is"
12+
)
13+
14+
func Test(t *testing.T) {
15+
is := is.New(t)
16+
out, err := exec.Command("./appify", "-name", "Test", "testdata/app").CombinedOutput()
17+
t.Logf("%q", string(out))
18+
is.NoErr(err)
19+
defer os.RemoveAll("Test.app")
20+
actualAppHash := filehash(t, "testdata/app")
21+
type file struct {
22+
path string
23+
perm string
24+
hash string
25+
}
26+
for _, f := range []file{
27+
{path: "Test.app", perm: "drwxr-xr-x"},
28+
{path: "Test.app/Contents", perm: "drwxr-xr-x"},
29+
{path: "Test.app/Contents/MacOS", perm: "drwxr-xr-x"},
30+
{path: "Test.app/Contents/MacOS/Test.app", perm: "-rwxr-xr-x", hash: actualAppHash},
31+
{path: "Test.app/Contents/Info.plist", perm: "-rw-r--r--", hash: "d263b0111cec1e6677970a35cc52f14d"},
32+
{path: "Test.app/Contents/README", perm: "-rw-r--r--", hash: "afeb10df47c7f189b848ae44a54e7e06"},
33+
} {
34+
t.Run(f.path, func(t *testing.T) {
35+
is := is.New(t)
36+
info, err := os.Stat(f.path)
37+
is.NoErr(err)
38+
is.Equal(info.Mode().String(), f.perm)
39+
if f.hash != "" {
40+
actual := filehash(t, f.path)
41+
is.Equal(actual, f.hash) // hash
42+
}
43+
})
44+
}
45+
}
46+
47+
// filehash gets an md5 hash of the file at path.
48+
func filehash(t *testing.T, path string) string {
49+
is := is.New(t)
50+
f, err := os.Open(path)
51+
is.NoErr(err)
52+
defer f.Close()
53+
h := md5.New()
54+
_, err = io.Copy(h, f)
55+
is.NoErr(err)
56+
return fmt.Sprintf("%x", h.Sum(nil))
57+
}

testdata/app.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package main
2+
3+
import "log"
4+
5+
func main() {
6+
log.Println("Hello world of desktop applications")
7+
}

0 commit comments

Comments
 (0)