Build double-click app bundles for macOS and Linux

Share the base++zim++trailer assembly behind a small helper, then add two bundle
builders on top: BuildApp writes a macOS .app (Info.plist, the viewer under
Contents/MacOS, an .icns in Resources), and BuildAppDir writes an AppImage-style
.AppDir (AppRun, a Terminal=false .desktop launcher, a PNG icon). Both keep the
trailer at the tail so Embedded still finds the archive at runtime.
This commit is contained in:
Duc-Tam Nguyen
2026-06-15 00:42:40 +07:00
parent 09543d1e11
commit e3d3c48ce0
5 changed files with 595 additions and 17 deletions
+161
View File
@@ -0,0 +1,161 @@
package pack
import (
"encoding/xml"
"fmt"
"image"
"os"
"path/filepath"
"strings"
)
// AppOptions controls how a macOS .app bundle is assembled around a packed
// viewer.
type AppOptions struct {
Out string // path to the .app directory
Base string // base kage binary (must be a macOS build); default os.Executable()
Name string // display name shown in Finder and the Dock
ExecName string // file name of the executable inside Contents/MacOS
Identifier string // CFBundleIdentifier, e.g. com.kage.paulgraham
Version string // CFBundleShortVersionString; default 1.0
Icon image.Image // optional; written as Resources/icon.icns
}
// BuildApp writes a double-clickable macOS application bundle that serves the
// packed site. Finder runs Contents/MacOS/<ExecName> with no terminal attached,
// which is the whole point: the bare appended binary opens a Terminal window
// when double-clicked, a .app does not. The executable is the same
// base++zim++trailer image BuildBinary produces, so Embedded finds the archive
// at runtime exactly as it does for a plain viewer.
//
// It returns the bundle path and the size of the executable inside it (the part
// that dominates, since the plist and icon are tiny).
func BuildApp(zimBytes []byte, opts AppOptions) (string, int64, error) {
if opts.Out == "" {
return "", 0, fmt.Errorf("pack: BuildApp requires an output path")
}
if filepath.Ext(opts.Out) != ".app" {
return "", 0, fmt.Errorf("pack: app bundle path must end in .app, got %q", opts.Out)
}
base := opts.Base
if base == "" {
exe, err := os.Executable()
if err != nil {
return "", 0, fmt.Errorf("pack: locate base binary: %w", err)
}
base = exe
}
baseBytes, err := os.ReadFile(base)
if err != nil {
return "", 0, fmt.Errorf("pack: read base binary %q: %w", base, err)
}
execName := opts.ExecName
if execName == "" {
execName = "kage"
}
name := opts.Name
if name == "" {
name = execName
}
version := opts.Version
if version == "" {
version = "1.0"
}
// Start from a clean bundle so a rebuild never leaves a stale icon or
// executable behind. The path is known to end in .app from the guard above.
if err := os.RemoveAll(opts.Out); err != nil {
return "", 0, err
}
contents := filepath.Join(opts.Out, "Contents")
macOS := filepath.Join(contents, "MacOS")
resources := filepath.Join(contents, "Resources")
if err := os.MkdirAll(macOS, 0o755); err != nil {
return "", 0, err
}
hasIcon := opts.Icon != nil
if hasIcon {
if err := os.MkdirAll(resources, 0o755); err != nil {
return "", 0, err
}
icns, err := EncodeICNS(opts.Icon)
if err != nil {
return "", 0, err
}
if err := os.WriteFile(filepath.Join(resources, "icon.icns"), icns, 0o644); err != nil {
return "", 0, err
}
}
plist := infoPlist(infoPlistData{
Name: name,
ExecName: execName,
Identifier: opts.Identifier,
Version: version,
HasIcon: hasIcon,
})
if err := os.WriteFile(filepath.Join(contents, "Info.plist"), []byte(plist), 0o644); err != nil {
return "", 0, err
}
// PkgInfo is the eight-byte legacy type/creator stamp; APPL???? is the
// generic application value every modern bundle uses.
if err := os.WriteFile(filepath.Join(contents, "PkgInfo"), []byte("APPL????"), 0o644); err != nil {
return "", 0, err
}
exe := assemble(baseBytes, zimBytes)
if err := os.WriteFile(filepath.Join(macOS, execName), exe, 0o755); err != nil {
return "", 0, err
}
return opts.Out, int64(len(exe)), nil
}
type infoPlistData struct {
Name string
ExecName string
Identifier string
Version string
HasIcon bool
}
// infoPlist renders the bundle's Info.plist. Values are XML-escaped so a site
// title with an ampersand or angle bracket cannot corrupt the file.
func infoPlist(d infoPlistData) string {
pairs := [][2]string{
{"CFBundleName", d.Name},
{"CFBundleDisplayName", d.Name},
{"CFBundleExecutable", d.ExecName},
{"CFBundleIdentifier", d.Identifier},
{"CFBundleInfoDictionaryVersion", "6.0"},
{"CFBundlePackageType", "APPL"},
{"CFBundleShortVersionString", d.Version},
{"CFBundleVersion", d.Version},
{"LSMinimumSystemVersion", "10.13"},
}
if d.HasIcon {
pairs = append(pairs, [2]string{"CFBundleIconFile", "icon"})
}
var b strings.Builder
b.WriteString(xml.Header)
b.WriteString(`<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">` + "\n")
b.WriteString(`<plist version="1.0">` + "\n<dict>\n")
for _, kv := range pairs {
b.WriteString("\t<key>" + esc(kv[0]) + "</key>\n")
b.WriteString("\t<string>" + esc(kv[1]) + "</string>\n")
}
// NSHighResolutionCapable is a boolean, not a string, so the icon and text
// render sharp on Retina displays.
b.WriteString("\t<key>NSHighResolutionCapable</key>\n\t<true/>\n")
b.WriteString("</dict>\n</plist>\n")
return b.String()
}
func esc(s string) string {
var b strings.Builder
_ = xml.EscapeText(&b, []byte(s))
return b.String()
}
+131
View File
@@ -0,0 +1,131 @@
package pack
import (
"encoding/binary"
"image/color"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestBuildApp(t *testing.T) {
dir := t.TempDir()
base := filepath.Join(dir, "kage-darwin")
baseBytes := []byte("\xcf\xfa\xed\xfeMACHO-BASE-BYTES")
if err := os.WriteFile(base, baseBytes, 0o755); err != nil {
t.Fatal(err)
}
zim := []byte("ZIM-ARCHIVE-PAYLOAD")
out := filepath.Join(dir, "Paulgraham.app")
path, size, err := BuildApp(zim, AppOptions{
Out: out,
Base: base,
Name: "Paul Graham",
ExecName: "paulgraham",
Identifier: "com.kage.paulgraham",
Version: "1.0",
Icon: solid(48, 48, color.RGBA{A: 0xff}),
})
if err != nil {
t.Fatal(err)
}
if path != out {
t.Errorf("path = %q, want %q", path, out)
}
// The bundle skeleton.
exe := filepath.Join(out, "Contents", "MacOS", "paulgraham")
for _, f := range []string{
filepath.Join(out, "Contents", "Info.plist"),
filepath.Join(out, "Contents", "PkgInfo"),
filepath.Join(out, "Contents", "Resources", "icon.icns"),
exe,
} {
if _, err := os.Stat(f); err != nil {
t.Errorf("missing bundle file %s: %v", f, err)
}
}
// The executable is base++zim++trailer, and size reports its length.
exeBytes, err := os.ReadFile(exe)
if err != nil {
t.Fatal(err)
}
if int64(len(exeBytes)) != size {
t.Errorf("size = %d, want %d", size, len(exeBytes))
}
if !strings.HasPrefix(string(exeBytes), string(baseBytes)) {
t.Error("executable does not start with the base bytes")
}
tr := exeBytes[len(exeBytes)-trailerLen:]
if string(tr[:8]) != trailerMagic || string(tr[trailerLen-8:]) != trailerMagic {
t.Error("trailer magic missing from the executable")
}
if got := binary.LittleEndian.Uint64(tr[8:16]); got != uint64(len(zim)) {
t.Errorf("trailer records zim length %d, want %d", got, len(zim))
}
// On a unix host the executable bit must be set so Finder can launch it.
if runtime.GOOS != "windows" {
info, err := os.Stat(exe)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm()&0o111 == 0 {
t.Errorf("executable mode = %v, want the execute bit set", info.Mode().Perm())
}
}
// Info.plist carries the identity and points at the icon.
plist, err := os.ReadFile(filepath.Join(out, "Contents", "Info.plist"))
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
"<string>paulgraham</string>", // CFBundleExecutable
"<string>Paul Graham</string>", // CFBundleName / DisplayName
"<string>com.kage.paulgraham</string>", // CFBundleIdentifier
"<key>CFBundleIconFile</key>",
"<string>icon</string>",
"NSHighResolutionCapable",
} {
if !strings.Contains(string(plist), want) {
t.Errorf("Info.plist missing %q", want)
}
}
}
func TestBuildAppNoIcon(t *testing.T) {
dir := t.TempDir()
base := filepath.Join(dir, "kage")
if err := os.WriteFile(base, []byte("\xcf\xfa\xed\xfeBASE"), 0o755); err != nil {
t.Fatal(err)
}
out := filepath.Join(dir, "Site.app")
if _, _, err := BuildApp([]byte("ZIM"), AppOptions{Out: out, Base: base, ExecName: "site"}); err != nil {
t.Fatal(err)
}
// No icon means no Resources dir and no icon key in the plist.
if _, err := os.Stat(filepath.Join(out, "Contents", "Resources")); !os.IsNotExist(err) {
t.Errorf("Resources dir should be absent without an icon, got err=%v", err)
}
plist, err := os.ReadFile(filepath.Join(out, "Contents", "Info.plist"))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(plist), "CFBundleIconFile") {
t.Error("plist names an icon file but none was provided")
}
}
func TestBuildAppRejectsBadOut(t *testing.T) {
if _, _, err := BuildApp([]byte("ZIM"), AppOptions{Out: "noapp", Base: "x"}); err == nil {
t.Error("BuildApp should reject an output path without a .app extension")
}
if _, _, err := BuildApp([]byte("ZIM"), AppOptions{Out: ""}); err == nil {
t.Error("BuildApp should reject an empty output path")
}
}
+156
View File
@@ -0,0 +1,156 @@
package pack
import (
"bytes"
"fmt"
"image"
"image/png"
"os"
"path/filepath"
"strings"
)
// LinuxAppOptions controls how a Linux application directory is assembled around
// a packed viewer.
type LinuxAppOptions struct {
Out string // path to the .AppDir directory
Base string // base kage binary (must be a Linux build); default os.Executable()
Name string // display name shown in menus
ExecName string // base name for the .desktop and icon files
Comment string // optional one-line description for the launcher
Version string // version string recorded in the .desktop
Icon image.Image // optional; written as the launcher icon
}
// BuildAppDir writes an AppImage-style application directory. The layout follows
// the AppDir convention so `appimagetool` can fold it into a single
// double-clickable .AppImage, but it is useful on its own: AppRun is the packed
// viewer, the .desktop file launches it with Terminal=false (no console), and
// the icon gives it a face in the file manager and menus.
//
// It returns the AppDir path, the size of the executable inside it, and whether
// an icon was written (the caller needs that to decide if an .AppImage can be
// built, since AppImage requires one).
func BuildAppDir(zimBytes []byte, opts LinuxAppOptions) (path string, size int64, hasIcon bool, err error) {
if opts.Out == "" {
return "", 0, false, fmt.Errorf("pack: BuildAppDir requires an output path")
}
if !strings.HasSuffix(opts.Out, ".AppDir") {
return "", 0, false, fmt.Errorf("pack: app dir path must end in .AppDir, got %q", opts.Out)
}
base := opts.Base
if base == "" {
exe, e := os.Executable()
if e != nil {
return "", 0, false, fmt.Errorf("pack: locate base binary: %w", e)
}
base = exe
}
baseBytes, err := os.ReadFile(base)
if err != nil {
return "", 0, false, fmt.Errorf("pack: read base binary %q: %w", base, err)
}
execName := opts.ExecName
if execName == "" {
execName = "kage"
}
name := opts.Name
if name == "" {
name = execName
}
if err := os.RemoveAll(opts.Out); err != nil {
return "", 0, false, err
}
if err := os.MkdirAll(opts.Out, 0o755); err != nil {
return "", 0, false, err
}
// AppRun is the AppImage entrypoint; pointing it straight at the packed
// binary keeps the directory to a single executable with no wrapper script.
exe := assemble(baseBytes, zimBytes)
if err := os.WriteFile(filepath.Join(opts.Out, "AppRun"), exe, 0o755); err != nil {
return "", 0, false, err
}
hasIcon = opts.Icon != nil
if hasIcon {
pngBytes, err := encodeIconPNG(opts.Icon, 256)
if err != nil {
return "", 0, false, err
}
// The icon lives at the root under the name the .desktop references, and
// .DirIcon is the AppImage convention for the directory's own icon.
if err := os.WriteFile(filepath.Join(opts.Out, execName+".png"), pngBytes, 0o644); err != nil {
return "", 0, false, err
}
if err := os.WriteFile(filepath.Join(opts.Out, ".DirIcon"), pngBytes, 0o644); err != nil {
return "", 0, false, err
}
}
desktop := desktopEntry(desktopData{
Name: name,
ExecName: execName,
Comment: opts.Comment,
Version: opts.Version,
HasIcon: hasIcon,
})
if err := os.WriteFile(filepath.Join(opts.Out, execName+".desktop"), []byte(desktop), 0o644); err != nil {
return "", 0, false, err
}
return opts.Out, int64(len(exe)), hasIcon, nil
}
type desktopData struct {
Name string
ExecName string
Comment string
Version string
HasIcon bool
}
// desktopEntry renders a freedesktop .desktop launcher. Terminal=false is the
// line that keeps a console from opening, the Linux echo of the macOS .app.
func desktopEntry(d desktopData) string {
var b strings.Builder
b.WriteString("[Desktop Entry]\n")
b.WriteString("Type=Application\n")
b.WriteString("Name=" + desktopValue(d.Name) + "\n")
if d.Comment != "" {
b.WriteString("Comment=" + desktopValue(d.Comment) + "\n")
}
// AppRun is on PATH inside a running AppImage, so Exec names it directly.
b.WriteString("Exec=AppRun\n")
if d.HasIcon {
b.WriteString("Icon=" + d.ExecName + "\n")
}
b.WriteString("Categories=Network;Utility;\n")
b.WriteString("Terminal=false\n")
if d.Version != "" {
b.WriteString("X-AppImage-Version=" + desktopValue(d.Version) + "\n")
}
return b.String()
}
// desktopValue strips the characters that would break a .desktop key=value line
// (newlines and the leading-space/escape pitfalls), which is enough for a name
// or comment drawn from a page title.
func desktopValue(s string) string {
s = strings.ReplaceAll(s, "\n", " ")
s = strings.ReplaceAll(s, "\r", " ")
return strings.TrimSpace(s)
}
// encodeIconPNG scales img to a px-by-px square and encodes it as PNG, the icon
// format the freedesktop world expects.
func encodeIconPNG(img image.Image, px int) ([]byte, error) {
var buf bytes.Buffer
if err := png.Encode(&buf, scaleSquare(img, px)); err != nil {
return nil, fmt.Errorf("pack: encode icon png: %w", err)
}
return buf.Bytes(), nil
}
+125
View File
@@ -0,0 +1,125 @@
package pack
import (
"image/color"
"image/png"
"os"
"path/filepath"
"strings"
"testing"
)
func TestBuildAppDir(t *testing.T) {
dir := t.TempDir()
base := filepath.Join(dir, "kage-linux")
baseBytes := []byte("\x7fELF-LINUX-BASE")
if err := os.WriteFile(base, baseBytes, 0o755); err != nil {
t.Fatal(err)
}
out := filepath.Join(dir, "paulgraham.AppDir")
path, size, hasIcon, err := BuildAppDir([]byte("ZIM-PAYLOAD"), LinuxAppOptions{
Out: out,
Base: base,
Name: "Paul Graham",
ExecName: "paulgraham",
Comment: "Offline mirror",
Version: "1.0",
Icon: solid(64, 64, color.RGBA{B: 0xff, A: 0xff}),
})
if err != nil {
t.Fatal(err)
}
if path != out || !hasIcon {
t.Fatalf("path=%q hasIcon=%v", path, hasIcon)
}
apprun := filepath.Join(out, "AppRun")
for _, f := range []string{
apprun,
filepath.Join(out, "paulgraham.desktop"),
filepath.Join(out, "paulgraham.png"),
filepath.Join(out, ".DirIcon"),
} {
if _, err := os.Stat(f); err != nil {
t.Errorf("missing %s: %v", f, err)
}
}
exe, err := os.ReadFile(apprun)
if err != nil {
t.Fatal(err)
}
if int64(len(exe)) != size {
t.Errorf("size = %d, want %d", size, len(exe))
}
if !strings.HasPrefix(string(exe), string(baseBytes)) {
t.Error("AppRun does not start with the base bytes")
}
tr := exe[len(exe)-trailerLen:]
if string(tr[:8]) != trailerMagic {
t.Error("AppRun missing the trailer")
}
if info, _ := os.Stat(apprun); info.Mode().Perm()&0o111 == 0 {
t.Error("AppRun is not executable")
}
// The icon is a real 256px PNG.
f, err := os.Open(filepath.Join(out, "paulgraham.png"))
if err != nil {
t.Fatal(err)
}
defer func() { _ = f.Close() }()
img, err := png.Decode(f)
if err != nil {
t.Fatalf("icon is not a PNG: %v", err)
}
if img.Bounds().Dx() != 256 {
t.Errorf("icon width = %d, want 256", img.Bounds().Dx())
}
desktop, err := os.ReadFile(filepath.Join(out, "paulgraham.desktop"))
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
"Name=Paul Graham",
"Exec=AppRun",
"Icon=paulgraham",
"Terminal=false",
"Comment=Offline mirror",
} {
if !strings.Contains(string(desktop), want) {
t.Errorf(".desktop missing %q", want)
}
}
}
func TestBuildAppDirNoIcon(t *testing.T) {
dir := t.TempDir()
base := filepath.Join(dir, "kage")
if err := os.WriteFile(base, []byte("\x7fELF"), 0o755); err != nil {
t.Fatal(err)
}
out := filepath.Join(dir, "site.AppDir")
_, _, hasIcon, err := BuildAppDir([]byte("ZIM"), LinuxAppOptions{Out: out, Base: base, ExecName: "site"})
if err != nil {
t.Fatal(err)
}
if hasIcon {
t.Error("hasIcon should be false without an icon")
}
if _, err := os.Stat(filepath.Join(out, "site.png")); !os.IsNotExist(err) {
t.Errorf("icon should be absent, got err=%v", err)
}
desktop, _ := os.ReadFile(filepath.Join(out, "site.desktop"))
if strings.Contains(string(desktop), "Icon=") {
t.Error(".desktop names an icon but none was written")
}
}
func TestBuildAppDirRejectsBadOut(t *testing.T) {
if _, _, _, err := BuildAppDir([]byte("Z"), LinuxAppOptions{Out: "site", Base: "x"}); err == nil {
t.Error("BuildAppDir should reject a path without .AppDir")
}
}
+22 -17
View File
@@ -44,26 +44,31 @@ func BuildBinary(zimBytes []byte, opts BinaryOptions) (string, int64, error) {
return "", 0, fmt.Errorf("pack: read base binary %q: %w", base, err)
}
payload := assemble(baseBytes, zimBytes)
if err := os.WriteFile(opts.Out, payload, 0o755); err != nil {
return opts.Out, 0, err
}
// WriteFile honours the mode only when it creates the file; chmod makes an
// overwrite executable too.
if err := os.Chmod(opts.Out, 0o755); err != nil {
return opts.Out, 0, err
}
return opts.Out, int64(len(payload)), nil
}
// assemble builds the self-contained viewer image: the base executable, then the
// ZIM archive, then the KAGEPCK1 trailer that records the archive length. ELF,
// PE, and Mach-O loaders all ignore trailing bytes, so the result still runs on
// its target OS while Embedded finds the archive at the tail.
func assemble(baseBytes, zimBytes []byte) []byte {
var tr bytes.Buffer
tr.WriteString(trailerMagic)
_ = binary.Write(&tr, binary.LittleEndian, uint64(len(zimBytes)))
tr.WriteString(trailerMagic)
f, err := os.Create(opts.Out)
if err != nil {
return "", 0, err
}
for _, chunk := range [][]byte{baseBytes, zimBytes, tr.Bytes()} {
if _, err := f.Write(chunk); err != nil {
_ = f.Close()
return opts.Out, 0, err
}
}
if err := f.Close(); err != nil {
return opts.Out, 0, err
}
if err := os.Chmod(opts.Out, 0o755); err != nil {
return opts.Out, 0, err
}
return opts.Out, int64(len(baseBytes) + len(zimBytes) + trailerLen), nil
out := make([]byte, 0, len(baseBytes)+len(zimBytes)+tr.Len())
out = append(out, baseBytes...)
out = append(out, zimBytes...)
out = append(out, tr.Bytes()...)
return out
}