@@ -1,10 +1,15 @@
package main
import (
+ "archive/tar"
"bytes"
+ "compress/gzip"
"encoding/base64"
+ "encoding/json"
"fmt"
+ "io"
"log"
+ "net/http"
"os"
"os/exec"
"path/filepath"
@@ -29,6 +34,29 @@ const (
paginationLimit = 20
)
+// Version variables are injected by the build (GoReleaser ldflags).
+// They default to "dev" when not set by the build system.
+var (
+ version = "dev"
+ commit = ""
+ date = ""
+)
+
+// UpdateAvailableMsg is sent into the TUI when a newer release is detected.
+type UpdateAvailableMsg struct {
+ Latest string
+ Current string
+}
+
+// internal struct for parsing GitHub release JSON.
+type githubRelease struct {
+ TagName string `json:"tag_name"`
+ Assets []struct {
+ Name string `json:"name"`
+ BrowserDownloadURL string `json:"browser_download_url"`
+ } `json:"assets"`
+}
+
type mainModel struct {
current tea.Model
previousModel tea.Model
@@ -56,7 +84,7 @@ func newInitialModel(cfg *config.Config) *mainModel {
}
func (m *mainModel) Init() tea.Cmd {
- return m.current.Init()
+ return tea.Batch(m.current.Init(), checkForUpdatesCmd())
}
func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -963,7 +991,302 @@ func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.Download
}
}
+/*
+detectInstalledVersion returns a best-effort installed version string.
+Priority:
+ 1. If the build-in `version` variable is set to something other than "dev", return it.
+ 2. If Homebrew is present and reports a version for `matcha`, return that.
+ 3. If snap is present and lists `matcha`, return that.
+ 4. Fallback to the build `version` (likely "dev").
+*/
+func detectInstalledVersion() string {
+ v := strings.TrimSpace(version)
+ if v != "dev" && v != "" {
+ return v
+ }
+
+ // Try Homebrew (macOS)
+ if runtime.GOOS == "darwin" {
+ if _, err := exec.LookPath("brew"); err == nil {
+ // `brew list --versions matcha` prints: matcha 1.2.3
+ if out, err := exec.Command("brew", "list", "--versions", "matcha").Output(); err == nil {
+ parts := strings.Fields(string(out))
+ if len(parts) >= 2 {
+ return parts[1]
+ }
+ }
+ }
+ }
+
+ // Try snap (Linux)
+ if runtime.GOOS == "linux" {
+ if _, err := exec.LookPath("snap"); err == nil {
+ if out, err := exec.Command("snap", "list", "matcha").Output(); err == nil {
+ lines := strings.Split(strings.TrimSpace(string(out)), "\n")
+ if len(lines) >= 2 {
+ fields := strings.Fields(lines[1])
+ if len(fields) >= 2 {
+ return fields[1]
+ }
+ }
+ }
+ }
+ }
+
+ return v
+}
+
+/*
+checkForUpdatesCmd queries GitHub for the latest release tag and returns a
+tea.Msg (UpdateAvailableMsg) if the latest version differs from the current
+installed version. This runs in the background when the TUI initializes.
+*/
+func checkForUpdatesCmd() tea.Cmd {
+ return func() tea.Msg {
+ // Non-fatal: if anything goes wrong we just don't show the update message.
+ const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
+ resp, err := http.Get(api)
+ if err != nil {
+ return nil
+ }
+ defer resp.Body.Close()
+
+ var rel githubRelease
+ if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
+ return nil
+ }
+
+ latest := strings.TrimPrefix(rel.TagName, "v")
+ installed := strings.TrimPrefix(detectInstalledVersion(), "v")
+ if latest != "" && installed != "" && latest != installed {
+ return UpdateAvailableMsg{Latest: latest, Current: installed}
+ }
+ return nil
+ }
+}
+
+// runUpdateCLI implements the CLI entrypoint for `matcha update`.
+// It detects the likely installation method and attempts the appropriate
+// update path (Homebrew, Snap, or GitHub release binary extract).
+func runUpdateCLI() error {
+ const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
+ resp, err := http.Get(api)
+ if err != nil {
+ return fmt.Errorf("could not query releases: %w", err)
+ }
+ defer resp.Body.Close()
+
+ var rel githubRelease
+ if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
+ return fmt.Errorf("could not parse release info: %w", err)
+ }
+
+ latestTag := rel.TagName
+ if strings.HasPrefix(latestTag, "v") {
+ latestTag = latestTag[1:]
+ }
+
+ fmt.Printf("Current version: %s\n", version)
+ fmt.Printf("Latest version: %s\n", latestTag)
+
+ // Quick check: if already up-to-date, exit
+ cur := version
+ if strings.HasPrefix(cur, "v") {
+ cur = cur[1:]
+ }
+ if latestTag == "" || cur == latestTag {
+ fmt.Println("Already up to date.")
+ return nil
+ }
+
+ // Detect Homebrew
+ if _, err := exec.LookPath("brew"); err == nil {
+ fmt.Println("Detected Homebrew — attempting to upgrade via brew.")
+ cmd := exec.Command("brew", "upgrade", "matcha")
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ if err := cmd.Run(); err == nil {
+ fmt.Println("Successfully upgraded via Homebrew.")
+ return nil
+ }
+ fmt.Printf("Homebrew upgrade failed: %v\n", err)
+ // fallthrough to other methods
+ }
+
+ // Detect snap
+ if _, err := exec.LookPath("snap"); err == nil {
+ // Check if matcha is installed as a snap
+ cmdCheck := exec.Command("snap", "list", "matcha")
+ if err := cmdCheck.Run(); err == nil {
+ fmt.Println("Detected Snap package — attempting to refresh.")
+ cmd := exec.Command("snap", "refresh", "matcha")
+ 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
+ }
+ }
+
+ // Otherwise attempt to download the proper release asset and replace the binary.
+ osName := runtime.GOOS
+ arch := runtime.GOARCH
+
+ // 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 := http.Get(assetURL)
+ if err != nil {
+ return fmt.Errorf("download failed: %w", err)
+ }
+ defer respAsset.Body.Close()
+
+ // 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)
+
+ 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)
+ outFile.Close()
+ if err != nil {
+ return fmt.Errorf("could not write asset to disk: %w", err)
+ }
+
+ // If it's a tar.gz, extract and find the `matcha` binary
+ var binPath string
+ if strings.HasSuffix(assetName, ".tar.gz") || strings.HasSuffix(assetName, ".tgz") {
+ f, err := os.Open(assetPath)
+ if err != nil {
+ return fmt.Errorf("could not open archive: %w", err)
+ }
+ defer f.Close()
+ gzr, err := gzip.NewReader(f)
+ if err != nil {
+ return fmt.Errorf("could not create gzip reader: %w", err)
+ }
+ tr := tar.NewReader(gzr)
+ for {
+ hdr, err := tr.Next()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return fmt.Errorf("error reading tar: %w", err)
+ }
+ name := filepath.Base(hdr.Name)
+ if name == "matcha" || strings.Contains(strings.ToLower(name), "matcha") && (hdr.Typeflag == tar.TypeReg) {
+ // write out the file
+ binPath = filepath.Join(tmpDir, "matcha")
+ out, err := os.Create(binPath)
+ if err != nil {
+ return fmt.Errorf("could not create binary file: %w", err)
+ }
+ if _, err := io.Copy(out, tr); err != nil {
+ out.Close()
+ return fmt.Errorf("could not extract binary: %w", err)
+ }
+ out.Close()
+ if err := os.Chmod(binPath, 0755); err != nil {
+ return fmt.Errorf("could not make binary executable: %w", err)
+ }
+ break
+ }
+ }
+ } else {
+ // For non-archive assets, assume the asset is the binary itself.
+ binPath = assetPath
+ if err := os.Chmod(binPath, 0755); err != nil {
+ // ignore chmod errors but warn
+ fmt.Printf("warning: could not chmod downloaded binary: %v\n", err)
+ }
+ }
+
+ if binPath == "" {
+ return fmt.Errorf("could not locate matcha binary inside the release artifact")
+ }
+
+ // Replace the running executable with the new binary
+ 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 {
+ return fmt.Errorf("could not open new binary: %w", err)
+ }
+ out, err := os.OpenFile(tmpNew, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
+ if err != nil {
+ in.Close()
+ return fmt.Errorf("could not create temp binary in target dir: %w", err)
+ }
+ if _, err := io.Copy(out, in); err != nil {
+ in.Close()
+ out.Close()
+ return fmt.Errorf("could not write new binary to disk: %w", err)
+ }
+ in.Close()
+ out.Close()
+
+ // Attempt to atomically replace
+ if err := os.Rename(tmpNew, execPath); err != nil {
+ return fmt.Errorf("could not replace executable: %w", err)
+ }
+
+ fmt.Println("Successfully updated matcha to", latestTag)
+ return nil
+}
+
func main() {
+ // If invoked as CLI update command, run updater and exit.
+ if len(os.Args) > 1 && os.Args[1] == "update" {
+ if err := runUpdateCLI(); err != nil {
+ fmt.Fprintf(os.Stderr, "update failed: %v\n", err)
+ os.Exit(1)
+ }
+ os.Exit(0)
+ }
+
cfg, err := config.LoadConfig()
var initialModel *mainModel
if err != nil {
@@ -2,6 +2,7 @@ package tui
import (
"fmt"
+ "reflect"
"strings"
tea "github.com/charmbracelet/bubbletea"
@@ -29,9 +30,12 @@ const choiceLogo = `
`
type Choice struct {
- cursor int
- choices []string
- hasSavedDrafts bool
+ cursor int
+ choices []string
+ hasSavedDrafts bool
+ UpdateAvailable bool
+ LatestVersion string
+ CurrentVersion string
}
func NewChoice() Choice {
@@ -42,8 +46,11 @@ func NewChoice() Choice {
}
choices = append(choices, "Settings")
return Choice{
- choices: choices,
- hasSavedDrafts: hasSavedDrafts,
+ choices: choices,
+ hasSavedDrafts: hasSavedDrafts,
+ UpdateAvailable: false,
+ LatestVersion: "",
+ CurrentVersion: "",
}
}
@@ -77,6 +84,28 @@ func (m Choice) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
}
+
+ // Handle update notification from other package without importing its type directly.
+ // We look for a struct named 'UpdateAvailableMsg' that contains 'Latest' and 'Current' string fields.
+ rv := reflect.ValueOf(msg)
+ if rv.IsValid() && rv.Kind() == reflect.Struct && rv.Type().Name() == "UpdateAvailableMsg" {
+ f := rv.FieldByName("Latest")
+ c := rv.FieldByName("Current")
+ updated := false
+ if f.IsValid() && f.Kind() == reflect.String {
+ m.LatestVersion = f.String()
+ updated = true
+ }
+ if c.IsValid() && c.Kind() == reflect.String {
+ m.CurrentVersion = c.String()
+ updated = true
+ }
+ if updated {
+ m.UpdateAvailable = true
+ return m, nil
+ }
+ }
+
return m, nil
}
@@ -88,6 +117,18 @@ func (m Choice) View() string {
b.WriteString(listHeader.Render("What would you like to do?"))
b.WriteString("\n\n")
+ // If we detected an update, show a short message under the header.
+ if m.UpdateAvailable {
+ updateStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("208")).Padding(0, 1)
+ cur := m.CurrentVersion
+ if cur == "" {
+ cur = "unknown"
+ }
+ msg := fmt.Sprintf("Update available: %s (installed: %s) — run `matcha update` to upgrade", m.LatestVersion, cur)
+ b.WriteString(updateStyle.Render(msg))
+ b.WriteString("\n\n")
+ }
+
for i, choice := range m.choices {
if m.cursor == i {
b.WriteString(selectedItemStyle.Render(fmt.Sprintf("> %s", choice)))