diff --git a/pack/appbundle.go b/pack/appbundle.go new file mode 100644 index 0000000..8f6dff1 --- /dev/null +++ b/pack/appbundle.go @@ -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/ 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(`` + "\n") + b.WriteString(`` + "\n\n") + for _, kv := range pairs { + b.WriteString("\t" + esc(kv[0]) + "\n") + b.WriteString("\t" + esc(kv[1]) + "\n") + } + // NSHighResolutionCapable is a boolean, not a string, so the icon and text + // render sharp on Retina displays. + b.WriteString("\tNSHighResolutionCapable\n\t\n") + b.WriteString("\n\n") + return b.String() +} + +func esc(s string) string { + var b strings.Builder + _ = xml.EscapeText(&b, []byte(s)) + return b.String() +} diff --git a/pack/appbundle_test.go b/pack/appbundle_test.go new file mode 100644 index 0000000..84844da --- /dev/null +++ b/pack/appbundle_test.go @@ -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{ + "paulgraham", // CFBundleExecutable + "Paul Graham", // CFBundleName / DisplayName + "com.kage.paulgraham", // CFBundleIdentifier + "CFBundleIconFile", + "icon", + "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") + } +} diff --git a/pack/appdir.go b/pack/appdir.go new file mode 100644 index 0000000..d9a3e8b --- /dev/null +++ b/pack/appdir.go @@ -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 +} diff --git a/pack/appdir_test.go b/pack/appdir_test.go new file mode 100644 index 0000000..ee9916f --- /dev/null +++ b/pack/appdir_test.go @@ -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") + } +} diff --git a/pack/binary.go b/pack/binary.go index 1eb6da1..4a1d476 100644 --- a/pack/binary.go +++ b/pack/binary.go @@ -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 }