@@ -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
}