355 lines
8.2 KiB
Go
355 lines
8.2 KiB
Go
package main
|
|
|
|
import (
|
|
"archive/zip"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
diskfs "github.com/diskfs/go-diskfs"
|
|
"github.com/diskfs/go-diskfs/disk"
|
|
"github.com/diskfs/go-diskfs/filesystem"
|
|
"github.com/diskfs/go-diskfs/filesystem/iso9660"
|
|
)
|
|
|
|
func main() {
|
|
bin := flag.String("bin", "", "path to darwin Navis binary")
|
|
outDir := flag.String("out", "", "output directory (e.g. dist/navis-release/darwin-arm64)")
|
|
version := flag.String("version", "1.4.1", "CFBundleShortVersionString")
|
|
arch := flag.String("arch", "arm64", "arch label for volume name")
|
|
flag.Parse()
|
|
if *bin == "" || *outDir == "" {
|
|
fmt.Fprintln(os.Stderr, "usage: packmac -bin <Navis> -out <dir> [-version 1.4.1] [-arch arm64]")
|
|
os.Exit(2)
|
|
}
|
|
if err := run(*bin, *outDir, *version, *arch); err != nil {
|
|
fmt.Fprintf(os.Stderr, "packmac: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func run(binPath, outDir, version, arch string) error {
|
|
if err := os.MkdirAll(outDir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
stage, err := os.MkdirTemp("", "navis-packmac-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.RemoveAll(stage)
|
|
|
|
appRoot := filepath.Join(stage, "Navis.app")
|
|
if err := writeAppBundle(appRoot, binPath, version); err != nil {
|
|
return err
|
|
}
|
|
readme := filepath.Join(stage, "README.txt")
|
|
if err := os.WriteFile(readme, []byte(readmeText(version, arch)), 0o644); err != nil {
|
|
return err
|
|
}
|
|
|
|
zipPath := filepath.Join(outDir, "Navis.app.zip")
|
|
if err := zipDir(zipPath, stage, []string{"Navis.app", "README.txt"}); err != nil {
|
|
return fmt.Errorf("zip: %w", err)
|
|
}
|
|
fmt.Println("wrote", zipPath)
|
|
|
|
dmgPath := filepath.Join(outDir, "Navis.dmg")
|
|
if err := writeDMG(dmgPath, appRoot, readme, "Navis-"+arch); err != nil {
|
|
return fmt.Errorf("dmg: %w", err)
|
|
}
|
|
fmt.Println("wrote", dmgPath)
|
|
return nil
|
|
}
|
|
|
|
func writeAppBundle(appRoot, binPath, version string) error {
|
|
macOSDir := filepath.Join(appRoot, "Contents", "MacOS")
|
|
if err := os.MkdirAll(macOSDir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
plist := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>CFBundleExecutable</key><string>Navis</string>
|
|
<key>CFBundleIdentifier</key><string>win.evilfox.navis</string>
|
|
<key>CFBundleName</key><string>Navis</string>
|
|
<key>CFBundlePackageType</key><string>APPL</string>
|
|
<key>CFBundleShortVersionString</key><string>%s</string>
|
|
<key>CFBundleVersion</key><string>%s</string>
|
|
<key>LSMinimumSystemVersion</key><string>11.0</string>
|
|
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
|
|
</dict>
|
|
</plist>
|
|
`, version, version)
|
|
if err := os.WriteFile(filepath.Join(appRoot, "Contents", "Info.plist"), []byte(plist), 0o644); err != nil {
|
|
return err
|
|
}
|
|
dest := filepath.Join(macOSDir, "Navis")
|
|
if err := copyFile(binPath, dest, 0o755); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func writeDMG(dmgPath, appRoot, readmePath, volume string) error {
|
|
_ = os.Remove(dmgPath)
|
|
appSize, err := dirSize(appRoot)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
readmeSize, err := fileSize(readmePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
need := appSize + readmeSize + 4*1024*1024
|
|
if need < 12*1024*1024 {
|
|
need = 12 * 1024 * 1024
|
|
}
|
|
// round up to 2048
|
|
need = ((need + 2047) / 2048) * 2048
|
|
|
|
mydisk, err := diskfs.Create(dmgPath, need, diskfs.SectorSizeDefault)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
mydisk.LogicalBlocksize = 2048
|
|
fs, err := mydisk.CreateFilesystem(disk.FilesystemSpec{
|
|
Partition: 0,
|
|
FSType: filesystem.TypeISO9660,
|
|
VolumeLabel: sanitizeVol(volume),
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := copyTreeToISO(fs, appRoot, "/Navis.app"); err != nil {
|
|
_ = fs.Close()
|
|
return err
|
|
}
|
|
if err := writeISOFile(fs, "/README.txt", readmePath, 0o644); err != nil {
|
|
_ = fs.Close()
|
|
return err
|
|
}
|
|
|
|
iso, ok := fs.(*iso9660.FileSystem)
|
|
if !ok {
|
|
_ = fs.Close()
|
|
return fmt.Errorf("not iso9660")
|
|
}
|
|
if err := iso.Finalize(iso9660.FinalizeOptions{
|
|
RockRidge: true,
|
|
Joliet: true,
|
|
DeepDirectories: true,
|
|
VolumeIdentifier: sanitizeVol(volume),
|
|
PublisherIdentifier: "Navis",
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
return fs.Close()
|
|
}
|
|
|
|
func copyTreeToISO(fs filesystem.FileSystem, srcRoot, dstRoot string) error {
|
|
return filepath.Walk(srcRoot, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel, err := filepath.Rel(srcRoot, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel = filepath.ToSlash(rel)
|
|
dst := dstRoot
|
|
if rel != "." {
|
|
dst = dstRoot + "/" + rel
|
|
}
|
|
if info.IsDir() {
|
|
if dst == dstRoot {
|
|
return fs.Mkdir(dst)
|
|
}
|
|
return fs.Mkdir(dst)
|
|
}
|
|
mode := info.Mode()
|
|
if mode&0o111 != 0 || strings.HasSuffix(rel, "MacOS/Navis") || strings.HasSuffix(rel, "MacOS\\Navis") {
|
|
mode = 0o755
|
|
} else {
|
|
mode = 0o644
|
|
}
|
|
return writeISOFile(fs, dst, path, mode)
|
|
})
|
|
}
|
|
|
|
func writeISOFile(fs filesystem.FileSystem, dst, src string, mode os.FileMode) error {
|
|
in, err := os.Open(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer in.Close()
|
|
rw, err := fs.OpenFile(dst, os.O_CREATE|os.O_RDWR|os.O_TRUNC)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := io.Copy(rw, in); err != nil {
|
|
_ = rw.Close()
|
|
return err
|
|
}
|
|
if err := rw.Close(); err != nil {
|
|
return err
|
|
}
|
|
if err := fs.Chmod(dst, mode); err != nil {
|
|
_ = err // best-effort
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func zipDir(zipPath, stage string, entries []string) error {
|
|
_ = os.Remove(zipPath)
|
|
f, err := os.Create(zipPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
zw := zip.NewWriter(f)
|
|
defer zw.Close()
|
|
|
|
for _, name := range entries {
|
|
root := filepath.Join(stage, name)
|
|
info, err := os.Stat(root)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !info.IsDir() {
|
|
if err := addZipFile(zw, root, name, 0o644); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
err = filepath.Walk(root, func(path string, fi os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel, err := filepath.Rel(stage, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel = filepath.ToSlash(rel)
|
|
if fi.IsDir() {
|
|
_, err := zw.Create(rel + "/")
|
|
return err
|
|
}
|
|
mode := os.FileMode(0o644)
|
|
if strings.Contains(rel, "Contents/MacOS/") {
|
|
mode = 0o755
|
|
}
|
|
return addZipFile(zw, path, rel, mode)
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func addZipFile(zw *zip.Writer, path, name string, mode os.FileMode) error {
|
|
h, err := zip.FileInfoHeader(mustStat(path))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
h.Name = name
|
|
h.Method = zip.Deflate
|
|
h.SetMode(mode)
|
|
w, err := zw.CreateHeader(h)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
in, err := os.Open(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer in.Close()
|
|
_, err = io.Copy(w, in)
|
|
return err
|
|
}
|
|
|
|
func mustStat(path string) os.FileInfo {
|
|
fi, err := os.Stat(path)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return fi
|
|
}
|
|
|
|
func copyFile(src, dst string, mode os.FileMode) error {
|
|
in, err := os.Open(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer in.Close()
|
|
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer out.Close()
|
|
_, err = io.Copy(out, in)
|
|
return err
|
|
}
|
|
|
|
func dirSize(root string) (int64, error) {
|
|
var n int64
|
|
err := filepath.Walk(root, func(_ string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !info.IsDir() {
|
|
n += info.Size()
|
|
}
|
|
return nil
|
|
})
|
|
return n, err
|
|
}
|
|
|
|
func fileSize(path string) (int64, error) {
|
|
fi, err := os.Stat(path)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return fi.Size(), nil
|
|
}
|
|
|
|
func sanitizeVol(s string) string {
|
|
s = strings.ToUpper(s)
|
|
s = strings.Map(func(r rune) rune {
|
|
if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
|
|
return r
|
|
}
|
|
return '-'
|
|
}, s)
|
|
if len(s) > 32 {
|
|
s = s[:32]
|
|
}
|
|
if s == "" {
|
|
s = "NAVIS"
|
|
}
|
|
return s
|
|
}
|
|
|
|
func readmeText(version, arch string) string {
|
|
return fmt.Sprintf(`Navis %s for macOS (%s)
|
|
|
|
1) Open this disk image
|
|
2) Drag Navis.app to Applications (or any folder)
|
|
3) First launch may need:
|
|
xattr -dr com.apple.quarantine /Applications/Navis.app
|
|
4) Run from Terminal:
|
|
/Applications/Navis.app/Contents/MacOS/Navis init
|
|
/Applications/Navis.app/Contents/MacOS/Navis install-core
|
|
/Applications/Navis.app/Contents/MacOS/Navis connect
|
|
|
|
Or use the CLI binary next to this DMG on git (file named Navis).
|
|
|
|
Shop: https://evilfox.win/
|
|
`, version, arch)
|
|
}
|