//go:build darwin package update import ( "context" "fmt" "os" "os/exec" "path/filepath" "strconv" "strings" ) // Apply downloads the new binary or .app.zip and schedules replacement + relaunch after exit. func Apply(ctx context.Context, manifestURL string) (string, error) { p, err := prepareUpdate(ctx, manifestURL) if err != nil { return "", err } if p.Kind == AssetKindAppZip { return applyAppZip(p) } return applyBinary(p) } func applyBinary(p preparedUpdate) (string, error) { _ = os.Chmod(p.TmpPath, 0o755) dir := filepath.Dir(p.ExePath) scriptPath := filepath.Join(dir, "navis-update.sh") pid := os.Getpid() script := "#!/bin/bash\n" + "set -e\n" + "EXE=" + shellQuote(p.ExePath) + "\n" + "NEW=" + shellQuote(p.TmpPath) + "\n" + "PID=" + strconv.Itoa(pid) + "\n" + "while kill -0 \"$PID\" 2>/dev/null; do sleep 0.4; done\n" + "sleep 0.5\n" + "mv -f \"$NEW\" \"$EXE\"\n" + "chmod +x \"$EXE\"\n" + "rm -f " + shellQuote(scriptPath) + "\n" + "exec \"$EXE\"\n" if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil { return "", err } cmd := exec.Command("/bin/bash", scriptPath) cmd.Dir = dir if err := cmd.Start(); err != nil { return "", fmt.Errorf("start updater: %w", err) } return p.Latest, nil } func applyAppZip(p preparedUpdate) (string, error) { if p.AppRoot == "" { return "", fmt.Errorf("update: empty app root") } parent := filepath.Dir(p.AppRoot) scriptPath := filepath.Join(parent, "navis-update-app.sh") pid := os.Getpid() identity := strings.TrimSpace(os.Getenv("NAVIS_CODESIGN_IDENTITY")) // Never re-sign with ad-hoc "-" after replacing a shipped .app — that strips // Developer ID / notarization. Only re-sign when a real identity is set. doSign := identity != "" && identity != "-" script := "#!/bin/bash\n" + "set -e\n" + "APP=" + shellQuote(p.AppRoot) + "\n" + "ZIP=" + shellQuote(p.TmpPath) + "\n" + "PID=" + strconv.Itoa(pid) + "\n" + "PARENT=$(dirname \"$APP\")\n" + "while kill -0 \"$PID\" 2>/dev/null; do sleep 0.4; done\n" + "sleep 0.5\n" + "TMP=$(mktemp -d \"$PARENT/.navis-update-XXXXXX\")\n" + "unzip -q \"$ZIP\" -d \"$TMP\"\n" + "NEW=$(find \"$TMP\" -maxdepth 3 -name 'Navis.app' -type d | head -n 1)\n" + "if [ -z \"$NEW\" ] || [ ! -d \"$NEW\" ]; then echo 'Navis.app missing in zip' >&2; exit 1; fi\n" + "rm -rf \"$APP.bak\"\n" + "mv \"$APP\" \"$APP.bak\"\n" + "mv \"$NEW\" \"$APP\"\n" if doSign { script += "ID=" + shellQuote(identity) + "\n" + "if command -v codesign >/dev/null 2>&1; then\n" + " codesign -s \"$ID\" --force --deep --options runtime --timestamp \"$APP\"\n" + " codesign --verify --deep --strict \"$APP\"\n" + "fi\n" } script += "xattr -cr \"$APP\" 2>/dev/null || true\n" + "rm -rf \"$APP.bak\" \"$TMP\" \"$ZIP\" " + shellQuote(scriptPath) + "\n" + "open \"$APP\"\n" if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil { return "", err } cmd := exec.Command("/bin/bash", scriptPath) cmd.Dir = parent if err := cmd.Start(); err != nil { return "", fmt.Errorf("start app updater: %w", err) } return p.Latest, nil } func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } // CleanupStaleDownloads removes leftover update temps from failed/old runs. func CleanupStaleDownloads() { exe, err := os.Executable() if err != nil { return } exe, _ = filepath.Abs(exe) _ = os.Remove(exe + ".new") dir := filepath.Dir(exe) _ = os.Remove(filepath.Join(dir, ".navis-update-download")) _ = os.Remove(filepath.Join(dir, ".navis-update-app.zip")) if root, ok := AppBundleRoot(exe); ok { parent := filepath.Dir(root) _ = os.Remove(filepath.Join(parent, ".navis-update-app.zip")) _ = os.Remove(filepath.Join(parent, "navis-update-app.sh")) _ = os.Remove(filepath.Join(parent, "navis-update.sh")) } }