Files
navi/tools/packmac/main.go
T
M4andCursor dc700f2bac Merge origin/main: Navis 2.7.2+2 with app icon and update UX.
Combine remote update check/skip flow with green N AppIcon packaging for macOS.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 18:08:01 +03:00

593 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"archive/zip"
"flag"
"fmt"
"io"
"os"
"os/exec"
"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")
build := flag.String("build", "", "CFBundleVersion (defaults to -version)")
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 2.7.2] [-build 2.7.2.1] [-arch arm64]")
os.Exit(2)
}
bundleVer := *build
if bundleVer == "" {
bundleVer = *version
}
if err := run(*bin, *outDir, *version, bundleVer, *arch); err != nil {
fmt.Fprintf(os.Stderr, "packmac: %v\n", err)
os.Exit(1)
}
}
func run(binPath, outDir, version, bundleVersion, 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, bundleVersion, arch); err != nil {
return err
}
if err := signAppBundle(appRoot); err != nil {
return err
}
readme := filepath.Join(stage, "README.txt")
if err := os.WriteFile(readme, []byte(readmeText(version, arch)), 0o644); err != nil {
return err
}
warnName := archWarningFilename(arch)
if warnName != "" {
if err := os.WriteFile(filepath.Join(stage, warnName), []byte(archWarningText(arch)), 0o644); err != nil {
return err
}
}
zipEntries := []string{"Navis.app", "README.txt"}
if warnName != "" {
zipEntries = append(zipEntries, warnName)
}
zipPath := filepath.Join(outDir, "Navis.app.zip")
if err := zipDir(zipPath, stage, zipEntries); err != nil {
return fmt.Errorf("zip: %w", err)
}
fmt.Println("wrote", zipPath)
dmgPath := filepath.Join(outDir, "Navis.dmg")
if err := writeNativeOrISODmg(dmgPath, stage, "Navis-"+arch); err != nil {
return fmt.Errorf("dmg: %w", err)
}
fmt.Println("wrote", dmgPath)
return nil
}
// writeNativeOrISODmg prefers hdiutil (real UDIF DMG) on macOS; ISO9660 breaks .app launch.
func writeNativeOrISODmg(dmgPath, stageDir, volume string) error {
if _, err := exec.LookPath("hdiutil"); err == nil {
return writeHdiutilDMG(dmgPath, stageDir, volume)
}
entries := []dmgEntry{}
_ = filepath.Walk(stageDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info == nil {
return err
}
rel, err := filepath.Rel(stageDir, path)
if err != nil || rel == "." {
return err
}
// Only top-level items for ISO packer.
if strings.Contains(rel, string(os.PathSeparator)) {
return nil
}
dst := "/" + filepath.ToSlash(rel)
entries = append(entries, dmgEntry{path, dst})
return nil
})
return writeDMG(dmgPath, entries, volume)
}
func writeHdiutilDMG(dmgPath, stageDir, volume string) error {
_ = os.Remove(dmgPath)
vol := sanitizeVol(volume)
cmd := exec.Command("hdiutil", "create",
"-volname", vol,
"-srcfolder", stageDir,
"-ov",
"-format", "UDZO",
dmgPath,
)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("hdiutil: %w: %s", err, strings.TrimSpace(string(out)))
}
// Re-sign after packaging is unnecessary for UDZO copy of already-signed .app,
// but clear quarantine on the dmg itself if present.
_ = exec.Command("xattr", "-cr", dmgPath).Run()
return nil
}
func writeAppBundle(appRoot, binPath, version, bundleVersion, arch string) error {
macOSDir := filepath.Join(appRoot, "Contents", "MacOS")
resDir := filepath.Join(appRoot, "Contents", "Resources")
if err := os.MkdirAll(macOSDir, 0o755); err != nil {
return err
}
if err := os.MkdirAll(resDir, 0o755); err != nil {
return err
}
archPriority := architecturePriorityXML(arch)
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>CFBundleDisplayName</key><string>Navis</string>
<key>CFBundleIconFile</key><string>AppIcon</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>NSHighResolutionCapable</key><true/>
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>%s
</dict>
</plist>
`, version, bundleVersion, archPriority)
if err := os.WriteFile(filepath.Join(appRoot, "Contents", "Info.plist"), []byte(plist), 0o644); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(appRoot, "Contents", "PkgInfo"), []byte("APPL????"), 0o644); err != nil {
return err
}
if icns := findAsset("navis.icns"); icns != "" {
if err := copyFile(icns, filepath.Join(resDir, "AppIcon.icns"), 0o644); err != nil {
return fmt.Errorf("app icon: %w", err)
}
}
dest := filepath.Join(macOSDir, "Navis")
if err := copyFile(binPath, dest, 0o755); err != nil {
return err
}
return nil
}
func signAppBundle(appRoot string) error {
if _, err := exec.LookPath("codesign"); err != nil {
return nil
}
// Fresh ad-hoc signature; entitlements not required for local/dev builds.
cmd := exec.Command("codesign", "-s", "-", "--force", "--deep", "--options", "runtime", appRoot)
out, err := cmd.CombinedOutput()
if err != nil {
// --options runtime can fail without a real Developer ID; retry plain ad-hoc.
cmd = exec.Command("codesign", "-s", "-", "--force", "--deep", appRoot)
out, err = cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("codesign %s: %w: %s", appRoot, err, strings.TrimSpace(string(out)))
}
}
_ = exec.Command("xattr", "-cr", appRoot).Run()
return nil
}
type dmgEntry struct {
srcPath string
dstPath string
}
func writeDMG(dmgPath string, files []dmgEntry, volume string) error {
_ = os.Remove(dmgPath)
var need int64
for _, f := range files {
if fi, err := os.Stat(f.srcPath); err == nil && !fi.IsDir() {
need += fi.Size()
} else if sz, err := dirSize(f.srcPath); err == nil {
need += sz
}
}
need += 4 * 1024 * 1024
if need < 12*1024*1024 {
need = 12 * 1024 * 1024
}
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
}
for _, f := range files {
info, err := os.Stat(f.srcPath)
if err != nil {
_ = fs.Close()
return err
}
if info.IsDir() {
if err := copyTreeToISO(fs, f.srcPath, f.dstPath); err != nil {
_ = fs.Close()
return err
}
continue
}
if err := writeISOFile(fs, f.dstPath, f.srcPath, 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
}
// findAsset locates a file under assets/ relative to cwd or the packmac binary.
func findAsset(name string) string {
candidates := []string{
filepath.Join("assets", name),
}
if exe, err := os.Executable(); err == nil {
dir := filepath.Dir(exe)
candidates = append(candidates,
filepath.Join(dir, "..", "..", "assets", name),
filepath.Join(dir, "..", "assets", name),
filepath.Join(dir, "assets", name),
)
}
if wd, err := os.Getwd(); err == nil {
candidates = append(candidates, filepath.Join(wd, "assets", name))
}
for _, c := range candidates {
if st, err := os.Stat(c); err == nil && !st.IsDir() {
abs, err := filepath.Abs(c)
if err == nil {
return abs
}
return c
}
}
return ""
}
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 {
switch arch {
case "universal":
return fmt.Sprintf(`Navis %s for macOS (универсальная сборка)
Подходит для Apple Silicon (M1/M2/M3/M4) и Intel Mac.
1) Перетащите Navis.app в Программы (Applications)
2) При первом запуске, если macOS блокирует:
xattr -dr com.apple.quarantine /Applications/Navis.app
3) В Терминале:
/Applications/Navis.app/Contents/MacOS/Navis init
/Applications/Navis.app/Contents/MacOS/Navis install-core
/Applications/Navis.app/Contents/MacOS/Navis connect
Магазин: https://evilfox.win/
`, version)
case "arm64":
return fmt.Sprintf(`Navis %s for macOS (Apple Silicon: M1/M2/M3/M4)
⚠️ Только для Mac с чипом Apple Silicon (arm64).
На Intel Mac скачайте darwin-amd64 или darwin-universal.
1) Перетащите Navis.app в Программы
2) xattr -dr com.apple.quarantine /Applications/Navis.app
3) /Applications/Navis.app/Contents/MacOS/Navis connect
Магазин: https://evilfox.win/
`, version)
case "amd64":
return fmt.Sprintf(`Navis %s for macOS (Intel x86_64)
⚠️ Только для Intel Mac.
На Mac M1/M2/M3/M4 эта сборка НЕ запустится — скачайте darwin-arm64 или darwin-universal.
1) Перетащите Navis.app в Программы
2) xattr -dr com.apple.quarantine /Applications/Navis.app
3) /Applications/Navis.app/Contents/MacOS/Navis connect
Магазин: https://evilfox.win/
`, version)
default:
return fmt.Sprintf(`Navis %s for macOS (%s)
1) Drag Navis.app to Applications
2) xattr -dr com.apple.quarantine /Applications/Navis.app
3) /Applications/Navis.app/Contents/MacOS/Navis connect
`, version, arch)
}
}
func archWarningFilename(arch string) string {
switch arch {
case "amd64":
return "ТОЛЬКО-INTEL-MAC.txt"
case "arm64":
return "ТОЛЬКО-APPLE-SILICON.txt"
default:
return ""
}
}
func archWarningText(arch string) string {
switch arch {
case "amd64":
return `ВНИМАНИЕ
Эта сборка Navis только для Intel Mac (x86_64).
Если у вас Mac mini / MacBook с чипом M1, M2, M3 или M4 —
закройте этот диск и скачайте универсальный DMG:
https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.dmg
Или arm64:
https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg
`
case "arm64":
return `ВНИМАНИЕ
Эта сборка Navis только для Apple Silicon (M1/M2/M3/M4).
Если у вас Intel Mac — скачайте darwin-amd64 или универсальный DMG:
https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.dmg
`
default:
return ""
}
}
func architecturePriorityXML(arch string) string {
switch arch {
case "universal":
return `
<key>LSArchitecturePriority</key>
<array>
<string>arm64</string>
<string>x86_64</string>
</array>`
case "arm64":
return `
<key>LSArchitecturePriority</key>
<array><string>arm64</string></array>
<key>LSRequiresNativeExecution</key>
<true/>`
case "amd64":
return `
<key>LSArchitecturePriority</key>
<array><string>x86_64</string></array>`
default:
return ""
}
}