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 -out [-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(` CFBundleExecutableNavis CFBundleIdentifierwin.evilfox.navis CFBundleNameNavis CFBundleDisplayNameNavis CFBundleIconFileAppIcon CFBundlePackageTypeAPPL CFBundleShortVersionString%s CFBundleVersion%s LSMinimumSystemVersion11.0 NSHighResolutionCapable CFBundleInfoDictionaryVersion6.0%s `, 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 } // NAVIS_CODESIGN_IDENTITY=Developer ID Application: … for release; default ad-hoc "-". identity := strings.TrimSpace(os.Getenv("NAVIS_CODESIGN_IDENTITY")) if identity == "" { identity = "-" } cmd := exec.Command("codesign", "-s", identity, "--force", "--deep", "--options", "runtime", appRoot) out, err := cmd.CombinedOutput() if err != nil { cmd = exec.Command("codesign", "-s", identity, "--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 ` LSArchitecturePriority arm64 x86_64 ` case "arm64": return ` LSArchitecturePriority arm64 LSRequiresNativeExecution ` case "amd64": return ` LSArchitecturePriority x86_64` default: return "" } }