diff --git a/main.go b/main.go index 1d933274b212dca8709822a89ff9c963ecfa87aa..e8c62cacc28157e2012adaf4e25011fe5ddbb3c6 100644 --- a/main.go +++ b/main.go @@ -77,6 +77,8 @@ var ( const ( goosDarwin = "darwin" + goosLinux = "linux" + goosWindows = "windows" folderInbox = "INBOX" actionKindDelete = "delete" actionKindArchive = "archive" @@ -3574,9 +3576,9 @@ func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.Download switch runtime.GOOS { case goosDarwin: cmd = exec.Command("open", p) //nolint:noctx - case "linux": + case goosLinux: cmd = exec.Command("xdg-open", p) //nolint:noctx - case "windows": + case goosWindows: // 'start' is a cmd builtin; provide an empty title argument to avoid interpreting the path as the title. cmd = exec.Command("cmd", "/c", "start", "", p) //nolint:noctx default: @@ -3620,7 +3622,7 @@ func detectInstalledVersion() string { } // Try WinGet (Windows) - if runtime.GOOS == "windows" { + if runtime.GOOS == goosWindows { if _, err := exec.LookPath("winget"); err == nil { if out, err := exec.Command("winget", "list", "--id", "floatpane.matcha", "--disable-interactivity").Output(); err == nil { //nolint:noctx lines := strings.Split(strings.TrimSpace(string(out)), "\n") @@ -3639,7 +3641,7 @@ func detectInstalledVersion() string { } // Try snap (Linux) - if runtime.GOOS == "linux" { + if runtime.GOOS == goosLinux { if _, err := exec.LookPath("snap"); err == nil { if out, err := exec.Command("snap", "list", "matcha").Output(); err == nil { //nolint:noctx lines := strings.Split(strings.TrimSpace(string(out)), "\n") @@ -3923,7 +3925,7 @@ func isFlagSet(fs *flag.FlagSet, name string) bool { return found } -func runUpdateCLI() (err error) { //nolint:gocyclo +func runUpdateCLI() (err error) { const api = "https://api.github.com/repos/floatpane/matcha/releases/latest" resp, err := httpClient.Get(api) if err != nil { @@ -3948,145 +3950,224 @@ func runUpdateCLI() (err error) { //nolint:gocyclo return nil } - // Detect Homebrew - if _, err := exec.LookPath("brew"); err == nil { - fmt.Println("Detected Homebrew — updating taps and attempting to upgrade via brew.") + // Determine OS and try package managers in priority order + osName := runtime.GOOS - updateCmd := exec.Command("brew", "update") //nolint:noctx - updateCmd.Stdout = os.Stdout - updateCmd.Stderr = os.Stderr - if err := updateCmd.Run(); err != nil { - fmt.Printf("Homebrew update failed: %v\n", err) - // continue to attempt upgrade even if update failed + switch osName { + case goosDarwin: // macOS + // Priority: Homebrew > Manual binary update + if tryHomebrewUpgrade() { + return nil } + // Fall through to manual binary download - upgradeCmd := exec.Command("brew", "upgrade", "floatpane/matcha/matcha") //nolint:noctx - upgradeCmd.Stdout = os.Stdout - upgradeCmd.Stderr = os.Stderr - if err := upgradeCmd.Run(); err == nil { - fmt.Println("Successfully upgraded via Homebrew.") + case goosLinux: // Linux + // Priority: Snap > Flatpak > AUR (yay) > Nix > Manual binary update + if trySnapRefresh() { return nil } - fmt.Printf("Homebrew upgrade failed: %v\n", err) - // fallthrough to other methods - } + if tryFlatpakUpdate() { + return nil + } + if tryAURUpdate() { + return nil + } + if tryNixUpdate() { + return nil + } + // Fall through to manual binary download - // Detect snap - if _, err := exec.LookPath("snap"); err == nil { - // Check if matcha is installed as a snap - cmdCheck := exec.Command("snap", "list", "matcha") //nolint:noctx - if err := cmdCheck.Run(); err == nil { - fmt.Println("Detected Snap package — attempting to refresh.") - cmd := exec.Command("snap", "refresh", "matcha") //nolint:noctx - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err == nil { - fmt.Println("Successfully refreshed snap.") - return nil - } - fmt.Printf("Snap refresh failed: %v\n", err) - // fallthrough + case goosWindows: // Windows + // Priority: WinGet > Scoop > Manual binary update + if tryWinGetUpgrade() { + return nil } - } - // Detect flatpak - if _, err := exec.LookPath("flatpak"); err == nil { - // Check if matcha is installed as a flatpak - cmdCheck := exec.Command("flatpak", "info", "com.floatpane.matcha") //nolint:noctx - if err := cmdCheck.Run(); err == nil { - fmt.Println("Detected Flatpak package — attempting to update.") - cmd := exec.Command("flatpak", "update", "-y", "com.floatpane.matcha") //nolint:noctx - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err == nil { - fmt.Println("Successfully updated flatpak.") - return nil - } - fmt.Printf("Flatpak update failed: %v\n", err) - // fallthrough + if tryScoopUpdate() { + return nil } + // Fall through to manual binary download } - // Detect WinGet - if _, err := exec.LookPath("winget"); err == nil { - cmdCheck := exec.Command("winget", "list", "--id", "floatpane.matcha", "--disable-interactivity") //nolint:noctx - if err := cmdCheck.Run(); err == nil { - fmt.Println("Detected WinGet package — attempting to upgrade.") - cmd := exec.Command("winget", "upgrade", "--id", "floatpane.matcha", "--disable-interactivity") //nolint:noctx - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err == nil { - fmt.Println("Successfully upgraded via WinGet.") - return nil - } - fmt.Printf("WinGet upgrade failed: %v\n", err) - // fallthrough - } + // If no package manager succeeded, fall back to manual binary download + return runUpdateCLIManual(latestTag, rel) +} + +// tryHomebrewUpgrade attempts to upgrade via Homebrew +func tryHomebrewUpgrade() bool { + if _, err := exec.LookPath("brew"); err != nil { + return false } - // Otherwise attempt to download the proper release asset and replace the binary. - osName := runtime.GOOS - arch := runtime.GOARCH + fmt.Println("Detected Homebrew — updating taps and attempting to upgrade via brew.") - // Try to find a matching asset - var assetURL, assetName string - for _, a := range rel.Assets { - n := strings.ToLower(a.Name) - if strings.Contains(n, osName) && strings.Contains(n, arch) && (strings.HasSuffix(n, ".tar.gz") || strings.HasSuffix(n, ".tgz") || strings.HasSuffix(n, ".zip")) { - assetURL = a.BrowserDownloadURL - assetName = a.Name - break - } + updateCmd := exec.Command("brew", "update") //nolint:noctx + updateCmd.Stdout = os.Stdout + updateCmd.Stderr = os.Stderr + if err := updateCmd.Run(); err != nil { + fmt.Printf("Homebrew update failed: %v\n", err) + // continue to attempt upgrade even if update failed } - if assetURL == "" { - // Try any asset that contains 'matcha' and os/arch as a fallback - for _, a := range rel.Assets { - n := strings.ToLower(a.Name) - if strings.Contains(n, "matcha") && (strings.Contains(n, osName) || strings.Contains(n, arch)) { - assetURL = a.BrowserDownloadURL - assetName = a.Name - break - } - } + + upgradeCmd := exec.Command("brew", "upgrade", "floatpane/matcha/matcha") //nolint:noctx + upgradeCmd.Stdout = os.Stdout + upgradeCmd.Stderr = os.Stderr + if err := upgradeCmd.Run(); err == nil { + fmt.Println("Successfully upgraded via Homebrew.") + return true } + fmt.Printf("Homebrew upgrade failed\n") + return false +} - if assetURL == "" { - return fmt.Errorf("no suitable release artifact found for %s/%s", osName, arch) +// trySnapRefresh attempts to refresh via Snap +func trySnapRefresh() bool { + if _, err := exec.LookPath("snap"); err != nil { + return false } - fmt.Printf("Found release asset: %s\n", assetName) - fmt.Println("Downloading...") + // Check if matcha is installed as a snap + cmdCheck := exec.Command("snap", "list", "matcha") //nolint:noctx + if err := cmdCheck.Run(); err != nil { + return false + } - // Download asset - respAsset, err := httpClient.Get(assetURL) - if err != nil { - return fmt.Errorf("download failed: %w", err) + fmt.Println("Detected Snap package — attempting to refresh.") + cmd := exec.Command("snap", "refresh", "matcha") //nolint:noctx + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err == nil { + fmt.Println("Successfully refreshed snap.") + return true } - defer respAsset.Body.Close() //nolint:errcheck + fmt.Printf("Snap refresh failed\n") + return false +} - // Create a temp file for the download - tmpDir, err := os.MkdirTemp("", "matcha-update-*") - if err != nil { - return fmt.Errorf("could not create temp dir: %w", err) +// tryFlatpakUpdate attempts to update via Flatpak +func tryFlatpakUpdate() bool { + if _, err := exec.LookPath("flatpak"); err != nil { + return false } - defer os.RemoveAll(tmpDir) //nolint:errcheck - assetPath := filepath.Join(tmpDir, assetName) - outFile, err := os.Create(assetPath) - if err != nil { - return fmt.Errorf("could not create temp file: %w", err) + // Check if matcha is installed as a flatpak + cmdCheck := exec.Command("flatpak", "info", "com.floatpane.matcha") //nolint:noctx + if err := cmdCheck.Run(); err != nil { + return false } - _, err = io.Copy(outFile, respAsset.Body) - if err != nil { - _ = outFile.Close() - return fmt.Errorf("could not write asset to disk: %w", err) + + fmt.Println("Detected Flatpak package — attempting to update.") + cmd := exec.Command("flatpak", "update", "-y", "com.floatpane.matcha") //nolint:noctx + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err == nil { + fmt.Println("Successfully updated flatpak.") + return true } - if err := outFile.Close(); err != nil { - return fmt.Errorf("could not finalize asset file: %w", err) + fmt.Printf("Flatpak update failed\n") + return false +} + +// tryAURUpdate attempts to update via AUR (using yay) +func tryAURUpdate() bool { + if _, err := exec.LookPath("yay"); err != nil { + return false + } + + // Check if matcha-client-bin is installed + cmdCheck := exec.Command("yay", "-Q", "matcha-client-bin") //nolint:noctx + if err := cmdCheck.Run(); err != nil { + return false + } + + fmt.Println("Detected AUR package (matcha-client-bin) — attempting to update via yay.") + cmd := exec.Command("yay", "-Syu", "--noconfirm", "matcha-client-bin") //nolint:noctx + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err == nil { + fmt.Println("Successfully updated via AUR.") + return true + } + fmt.Printf("AUR update failed\n") + return false +} + +// tryNixUpdate attempts to update via Nix +func tryNixUpdate() bool { + if _, err := exec.LookPath("nix"); err != nil { + return false + } + + // Check if matcha is in the user's profile + cmdCheck := exec.Command("nix", "profile", "list") //nolint:noctx + output, err := cmdCheck.Output() + if err != nil || !strings.Contains(string(output), "matcha") { + return false + } + + fmt.Println("Detected Nix package — attempting to update via nix profile upgrade.") + cmd := exec.Command("nix", "profile", "upgrade", "github:floatpane/matcha") //nolint:noctx + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err == nil { + fmt.Println("Successfully updated via Nix.") + return true + } + fmt.Printf("Nix update failed\n") + return false +} + +// tryWinGetUpgrade attempts to upgrade via WinGet +func tryWinGetUpgrade() bool { + if _, err := exec.LookPath("winget"); err != nil { + return false } + cmdCheck := exec.Command("winget", "list", "--id", "floatpane.matcha", "--disable-interactivity") //nolint:noctx + if err := cmdCheck.Run(); err != nil { + return false + } + + fmt.Println("Detected WinGet package — attempting to upgrade.") + cmd := exec.Command("winget", "upgrade", "--id", "floatpane.matcha", "--disable-interactivity") //nolint:noctx + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err == nil { + fmt.Println("Successfully upgraded via WinGet.") + return true + } + fmt.Printf("WinGet upgrade failed\n") + return false +} + +// tryScoopUpdate attempts to update via Scoop +func tryScoopUpdate() bool { + if _, err := exec.LookPath("scoop"); err != nil { + return false + } + + // Check if matcha is installed via scoop + cmdCheck := exec.Command("scoop", "list", "matcha") //nolint:noctx + if err := cmdCheck.Run(); err != nil { + return false + } + + fmt.Println("Detected Scoop package — attempting to update.") + cmd := exec.Command("scoop", "update", "matcha") //nolint:noctx + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err == nil { + fmt.Println("Successfully updated via Scoop.") + return true + } + fmt.Printf("Scoop update failed\n") + return false +} + +// extractBinaryFromArchive extracts the matcha binary from a tar.gz, tgz, or zip archive +func extractBinaryFromArchive(assetPath, assetName, tmpDir string) (string, error) { // Determine the expected binary name based on the OS. binaryName := "matcha" - if runtime.GOOS == "windows" { + if runtime.GOOS == goosWindows { binaryName = "matcha.exe" } @@ -4095,12 +4176,12 @@ func runUpdateCLI() (err error) { //nolint:gocyclo if strings.HasSuffix(assetName, ".tar.gz") || strings.HasSuffix(assetName, ".tgz") { //nolint:gocritic f, err := os.Open(assetPath) if err != nil { - return fmt.Errorf("could not open archive: %w", err) + return "", fmt.Errorf("could not open archive: %w", err) } defer f.Close() //nolint:errcheck gzr, err := gzip.NewReader(f) if err != nil { - return fmt.Errorf("could not create gzip reader: %w", err) + return "", fmt.Errorf("could not create gzip reader: %w", err) } tr := tar.NewReader(gzr) for { @@ -4109,24 +4190,24 @@ func runUpdateCLI() (err error) { //nolint:gocyclo break } if err != nil { - return fmt.Errorf("error reading tar: %w", err) + return "", fmt.Errorf("error reading tar: %w", err) } name := filepath.Base(hdr.Name) if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && (hdr.Typeflag == tar.TypeReg) { binPath = filepath.Join(tmpDir, binaryName) out, err := os.Create(binPath) if err != nil { - return fmt.Errorf("could not create binary file: %w", err) + return "", fmt.Errorf("could not create binary file: %w", err) } if _, err := io.Copy(out, tr); err != nil { //nolint:gosec _ = out.Close() - return fmt.Errorf("could not extract binary: %w", err) + return "", fmt.Errorf("could not extract binary: %w", err) } if err := out.Close(); err != nil { - return fmt.Errorf("could not finalize extracted binary: %w", err) + return "", fmt.Errorf("could not finalize extracted binary: %w", err) } if err := os.Chmod(binPath, 0755); err != nil { //nolint:gosec - return fmt.Errorf("could not make binary executable: %w", err) + return "", fmt.Errorf("could not make binary executable: %w", err) } break } @@ -4134,7 +4215,7 @@ func runUpdateCLI() (err error) { //nolint:gocyclo } else if strings.HasSuffix(assetName, ".zip") { zr, err := zip.OpenReader(assetPath) if err != nil { - return fmt.Errorf("could not open zip archive: %w", err) + return "", fmt.Errorf("could not open zip archive: %w", err) } defer zr.Close() //nolint:errcheck for _, zf := range zr.File { @@ -4142,28 +4223,28 @@ func runUpdateCLI() (err error) { //nolint:gocyclo if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && !zf.FileInfo().IsDir() { rc, err := zf.Open() if err != nil { - return fmt.Errorf("could not open file in zip: %w", err) + return "", fmt.Errorf("could not open file in zip: %w", err) } binPath = filepath.Join(tmpDir, binaryName) out, err := os.Create(binPath) if err != nil { rc.Close() //nolint:errcheck,gosec - return fmt.Errorf("could not create binary file: %w", err) + return "", fmt.Errorf("could not create binary file: %w", err) } if _, err := io.Copy(out, rc); err != nil { //nolint:gosec _ = out.Close() _ = rc.Close() - return fmt.Errorf("could not extract binary: %w", err) + return "", fmt.Errorf("could not extract binary: %w", err) } if err := out.Close(); err != nil { _ = rc.Close() - return fmt.Errorf("could not finalize extracted binary: %w", err) + return "", fmt.Errorf("could not finalize extracted binary: %w", err) } if err := rc.Close(); err != nil { - return fmt.Errorf("could not close zip entry: %w", err) + return "", fmt.Errorf("could not close zip entry: %w", err) } if err := os.Chmod(binPath, 0755); err != nil { //nolint:gosec - return fmt.Errorf("could not make binary executable: %w", err) + return "", fmt.Errorf("could not make binary executable: %w", err) } break } @@ -4178,17 +4259,20 @@ func runUpdateCLI() (err error) { //nolint:gocyclo } if binPath == "" { - return fmt.Errorf("could not locate matcha binary inside the release artifact") + return "", fmt.Errorf("could not locate matcha binary inside the release artifact") } - // Replace the running executable with the new binary + return binPath, nil +} + +// replaceExecutable atomically replaces the current executable with a new binary +func replaceExecutable(binPath, execDir string) error { execPath, err := os.Executable() if err != nil { return fmt.Errorf("could not determine executable path: %w", err) } // Write the new binary to a temp file in same dir, then rename for atomic replacement. - execDir := filepath.Dir(execPath) tmpNew := filepath.Join(execDir, fmt.Sprintf("matcha.new.%d", time.Now().Unix())) in, err := os.Open(binPath) if err != nil { @@ -4213,7 +4297,7 @@ func runUpdateCLI() (err error) { //nolint:gocyclo // On Windows, a running executable cannot be overwritten directly. // Move the old binary out of the way first, then rename the new one in. - if runtime.GOOS == "windows" { + if runtime.GOOS == goosWindows { oldPath := execPath + ".old" _ = os.Remove(oldPath) // clean up any previous leftover if err := os.Rename(execPath, oldPath); err != nil { @@ -4225,6 +4309,105 @@ func runUpdateCLI() (err error) { //nolint:gocyclo return fmt.Errorf("could not replace executable: %w", err) } + return nil +} + +// runUpdateCLIManual handles manual binary download and replacement +func runUpdateCLIManual(latestTag string, rel githubRelease) error { + // Otherwise attempt to download the proper release asset and replace the binary. + osName := runtime.GOOS + arch := runtime.GOARCH + + // Check if we have write permissions to the executable directory + execPath, err := os.Executable() + if err != nil { + return fmt.Errorf("could not determine executable path: %w", err) + } + execDir := filepath.Dir(execPath) + + // Test if we can write to the directory + testFile := filepath.Join(execDir, ".matcha_update_test") + if _, err := os.Create(testFile); err != nil { + // Cannot write - check if running with sudo or suggest it + if os.Geteuid() != 0 { + fmt.Println("\n⚠️ Permission denied: Cannot write to installation directory.") + fmt.Println(" Try running with sudo: sudo matcha update") + fmt.Println(" Or reinstall using your package manager.") + return fmt.Errorf("permission denied: cannot write to %s", execDir) + } + // Running as root but still can't write - actual permission issue + return fmt.Errorf("cannot write to installation directory %s: %w", execDir, err) + } + _ = os.Remove(testFile) // Clean up test file + + // Try to find a matching asset + var assetURL, assetName string + for _, a := range rel.Assets { + n := strings.ToLower(a.Name) + if strings.Contains(n, osName) && strings.Contains(n, arch) && (strings.HasSuffix(n, ".tar.gz") || strings.HasSuffix(n, ".tgz") || strings.HasSuffix(n, ".zip")) { + assetURL = a.BrowserDownloadURL + assetName = a.Name + break + } + } + if assetURL == "" { + // Try any asset that contains 'matcha' and os/arch as a fallback + for _, a := range rel.Assets { + n := strings.ToLower(a.Name) + if strings.Contains(n, "matcha") && (strings.Contains(n, osName) || strings.Contains(n, arch)) { + assetURL = a.BrowserDownloadURL + assetName = a.Name + break + } + } + } + + if assetURL == "" { + return fmt.Errorf("no suitable release artifact found for %s/%s", osName, arch) + } + + fmt.Printf("Found release asset: %s\n", assetName) + fmt.Println("Downloading...") + + // Download asset + respAsset, err := httpClient.Get(assetURL) + if err != nil { + return fmt.Errorf("download failed: %w", err) + } + defer respAsset.Body.Close() //nolint:errcheck + + // Create a temp file for the download + tmpDir, err := os.MkdirTemp("", "matcha-update-*") + if err != nil { + return fmt.Errorf("could not create temp dir: %w", err) + } + defer os.RemoveAll(tmpDir) //nolint:errcheck + + assetPath := filepath.Join(tmpDir, assetName) + outFile, err := os.Create(assetPath) + if err != nil { + return fmt.Errorf("could not create temp file: %w", err) + } + _, err = io.Copy(outFile, respAsset.Body) + if err != nil { + _ = outFile.Close() + return fmt.Errorf("could not write asset to disk: %w", err) + } + if err := outFile.Close(); err != nil { + return fmt.Errorf("could not finalize asset file: %w", err) + } + + // Extract binary from archive + binPath, err := extractBinaryFromArchive(assetPath, assetName, tmpDir) + if err != nil { + return err + } + + // Replace the executable + if err := replaceExecutable(binPath, execDir); err != nil { + return err + } + fmt.Println("Successfully updated matcha to", latestTag) return nil }