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 -out [-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(` CFBundleExecutableNavis CFBundleIdentifierwin.evilfox.navis CFBundleNameNavis CFBundlePackageTypeAPPL CFBundleShortVersionString%s CFBundleVersion%s LSMinimumSystemVersion11.0 CFBundleInfoDictionaryVersion6.0 `, 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) }