Initial commit on Forgejo
Fresh repository history for elektrine/tarakan-client hosted at https://git.elektrine.com/elektrine/tarakan-client.
This commit is contained in:
commit
58b29a7f84
70 changed files with 12994 additions and 0 deletions
263
internal/updatecheck/updatecheck.go
Normal file
263
internal/updatecheck/updatecheck.go
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
// Package updatecheck tells users when a newer tarakan release is available.
|
||||
package updatecheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultRepo is where official release binaries are published.
|
||||
DefaultRepo = "atomine-elektrine/tarakan-client"
|
||||
// InstallHint is the one-liner people already use to install or upgrade.
|
||||
InstallHint = "curl -fsSL https://tarakan.lol/install.sh | bash"
|
||||
cacheTTL = 24 * time.Hour
|
||||
httpTimeout = 3 * time.Second
|
||||
)
|
||||
|
||||
// githubAPIBase is overridden in tests.
|
||||
var githubAPIBase = "https://api.github.com"
|
||||
|
||||
// Result is one comparison of the running binary against the latest release.
|
||||
type Result struct {
|
||||
Current string
|
||||
Latest string
|
||||
UpdateAvailable bool
|
||||
}
|
||||
|
||||
// Notice is a short human line for stderr (empty when no update).
|
||||
func (r Result) Notice() string {
|
||||
if !r.UpdateAvailable || r.Latest == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"tarakan %s is available (you have %s). Update:\n %s",
|
||||
displayVersion(r.Latest),
|
||||
displayVersion(r.Current),
|
||||
InstallHint,
|
||||
)
|
||||
}
|
||||
|
||||
// MaybeNotify checks for a newer release and prints a notice to w when one
|
||||
// exists. Network and disk errors are ignored so update checks never break
|
||||
// normal CLI work. Set TARAKAN_SKIP_UPDATE_CHECK=1 to disable.
|
||||
func MaybeNotify(w io.Writer, current string) {
|
||||
if w == nil || skipEnabled() {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), httpTimeout)
|
||||
defer cancel()
|
||||
result, err := Check(ctx, current)
|
||||
if err != nil || !result.UpdateAvailable {
|
||||
return
|
||||
}
|
||||
if notice := result.Notice(); notice != "" {
|
||||
fmt.Fprintln(w, notice)
|
||||
}
|
||||
}
|
||||
|
||||
// Check returns whether current is older than the latest GitHub release.
|
||||
// Results are cached under the user config dir for cacheTTL.
|
||||
func Check(ctx context.Context, current string) (Result, error) {
|
||||
current = normalizeVersion(current)
|
||||
result := Result{Current: current}
|
||||
if current == "" || current == "dev" {
|
||||
return result, nil
|
||||
}
|
||||
if skipEnabled() {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
latest, fromCache, err := latestVersion(ctx)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Latest = latest
|
||||
result.UpdateAvailable = compareVersions(current, latest) < 0
|
||||
_ = fromCache
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func skipEnabled() bool {
|
||||
v := strings.TrimSpace(os.Getenv("TARAKAN_SKIP_UPDATE_CHECK"))
|
||||
return v == "1" || strings.EqualFold(v, "true") || strings.EqualFold(v, "yes")
|
||||
}
|
||||
|
||||
func latestVersion(ctx context.Context) (string, bool, error) {
|
||||
if cached, ok := readCache(); ok {
|
||||
return cached, true, nil
|
||||
}
|
||||
latest, err := fetchLatestGitHub(ctx, DefaultRepo)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
_ = writeCache(latest)
|
||||
return latest, false, nil
|
||||
}
|
||||
|
||||
type cacheFile struct {
|
||||
Latest string `json:"latest"`
|
||||
CheckedAt time.Time `json:"checked_at"`
|
||||
}
|
||||
|
||||
func cachePath() (string, error) {
|
||||
dir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, "tarakan", "update-check.json"), nil
|
||||
}
|
||||
|
||||
func readCache() (string, bool) {
|
||||
path, err := cachePath()
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
var c cacheFile
|
||||
if err := json.Unmarshal(raw, &c); err != nil {
|
||||
return "", false
|
||||
}
|
||||
if c.Latest == "" || time.Since(c.CheckedAt) > cacheTTL {
|
||||
return "", false
|
||||
}
|
||||
return normalizeVersion(c.Latest), true
|
||||
}
|
||||
|
||||
func writeCache(latest string) error {
|
||||
path, err := cachePath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := json.MarshalIndent(cacheFile{
|
||||
Latest: normalizeVersion(latest),
|
||||
CheckedAt: time.Now().UTC(),
|
||||
}, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
return os.WriteFile(path, raw, 0o600)
|
||||
}
|
||||
|
||||
func fetchLatestGitHub(ctx context.Context, repo string) (string, error) {
|
||||
repo = strings.TrimSpace(repo)
|
||||
if repo == "" {
|
||||
return "", errors.New("empty repo")
|
||||
}
|
||||
url := strings.TrimRight(githubAPIBase, "/") + "/repos/" + repo + "/releases/latest"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("User-Agent", "tarakan-client-updatecheck")
|
||||
|
||||
client := &http.Client{Timeout: httpTimeout}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("github releases: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
TagName string `json:"tag_name"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil {
|
||||
return "", err
|
||||
}
|
||||
tag := normalizeVersion(body.TagName)
|
||||
if tag == "" {
|
||||
return "", errors.New("github releases: empty tag_name")
|
||||
}
|
||||
return tag, nil
|
||||
}
|
||||
|
||||
func displayVersion(v string) string {
|
||||
v = normalizeVersion(v)
|
||||
if v == "" {
|
||||
return "unknown"
|
||||
}
|
||||
return "v" + v
|
||||
}
|
||||
|
||||
// normalizeVersion strips a leading "v" and whitespace.
|
||||
func normalizeVersion(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
v = strings.TrimPrefix(v, "v")
|
||||
v = strings.TrimPrefix(v, "V")
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
// compareVersions returns -1 if a < b, 0 if equal, 1 if a > b.
|
||||
// Handles dotted numeric versions (1.2.3). Missing trailing segments are 0
|
||||
// (so "0.2" equals "0.2.0"). Non-numeric tails compare as strings.
|
||||
func compareVersions(a, b string) int {
|
||||
a = normalizeVersion(a)
|
||||
b = normalizeVersion(b)
|
||||
if a == b {
|
||||
return 0
|
||||
}
|
||||
as := strings.Split(a, ".")
|
||||
bs := strings.Split(b, ".")
|
||||
n := len(as)
|
||||
if len(bs) > n {
|
||||
n = len(bs)
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
an, aOk := versionPart(as, i)
|
||||
bn, bOk := versionPart(bs, i)
|
||||
if aOk && bOk {
|
||||
if an < bn {
|
||||
return -1
|
||||
}
|
||||
if an > bn {
|
||||
return 1
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Fall back to string compare for pre-release-ish segments.
|
||||
ap, bp := "", ""
|
||||
if i < len(as) {
|
||||
ap = as[i]
|
||||
}
|
||||
if i < len(bs) {
|
||||
bp = bs[i]
|
||||
}
|
||||
if ap < bp {
|
||||
return -1
|
||||
}
|
||||
if ap > bp {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func versionPart(parts []string, i int) (int, bool) {
|
||||
if i >= len(parts) || parts[i] == "" {
|
||||
return 0, true
|
||||
}
|
||||
n, err := strconv.Atoi(parts[i])
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue