e3d3c48ce0
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.
157 lines
4.9 KiB
Go
157 lines
4.9 KiB
Go
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
|
|
}
|