Release 1.4.1: add macOS DMG and Navis.app.zip to release artifacts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Navis
2026-07-28 08:09:21 +03:00
co-authored by Cursor
parent 4c2d6b8be7
commit a1e9019dcd
18 changed files with 542 additions and 54 deletions
+354
View File
@@ -0,0 +1,354 @@
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)
}