555 lines
14 KiB
Go
555 lines
14 KiB
Go
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")
|
||
if err := os.MkdirAll(macOSDir, 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>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
|
||
}
|
||
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
|
||
}
|
||
|
||
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 ""
|
||
}
|
||
}
|