From edbe3c3666410e1e3a57b63411c6bf50411c27e2 Mon Sep 17 00:00:00 2001 From: drew Date: Tue, 16 Dec 2025 21:19:19 +0400 Subject: [PATCH] feat: version check and "matcha update" --- .github/workflows/release.yml | 6 +- .gitignore | 3 +- main.go | 325 +++++++++++++++++++++++++++++++++- tui/choice.go | 51 +++++- 4 files changed, 373 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 15d19b5..ab75e3f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,11 +1,7 @@ name: Release on: - workflow_run: - workflows: ["Go CI"] - types: [completed] - branches: - - master + workflow_dispatch: permissions: contents: write # to create releases, tags and upload assets diff --git a/.gitignore b/.gitignore index c14a297..f2eb52b 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -go.sum \ No newline at end of file +go.sum +matcha diff --git a/main.go b/main.go index e0ea8b4..5fd9bde 100644 --- a/main.go +++ b/main.go @@ -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 { diff --git a/tui/choice.go b/tui/choice.go index 47bea29..cae9950 100644 --- a/tui/choice.go +++ b/tui/choice.go @@ -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)))