Files

191 lines
5.4 KiB
Go

// Package update talks to the public thefeed-files repo to find out
// whether a newer client is available, and hands the frontend a
// platform-correct download URL.
//
// This is independent of the in-protocol /api/version-check flow that
// reads the version from the server over DNS — that path requires a
// configured profile, while this one works as soon as the binary boots
// and only needs plain HTTPS reachability.
package update
import (
"context"
"fmt"
"io"
"net/http"
"os"
"runtime"
"strconv"
"strings"
"time"
"github.com/sartoopjj/thefeed/internal/version"
)
// BaseURL is the directory the release assets live under. The "raw"
// path resolves to the actual file bytes; pointing the user's browser
// here triggers a download.
const BaseURL = "https://github.com/sartoopjj/thefeed-files/raw/main/clients"
// VersionURL returns the plain-text VERSION file. Plain raw.githubusercontent
// host avoids the HTML wrapper github.com puts around blob views.
const VersionURL = "https://raw.githubusercontent.com/sartoopjj/thefeed-files/main/clients/VERSION"
// Status is the JSON returned to the frontend.
type Status struct {
Current string `json:"current"`
Latest string `json:"latest"`
HasUpdate bool `json:"hasUpdate"`
DownloadURL string `json:"downloadURL"`
}
// httpClient is shared so we get connection reuse between repeated
// background checks. 30s is plenty for a 16-byte text file.
var httpClient = &http.Client{Timeout: 30 * time.Second}
// Check fetches the VERSION file and assembles a Status for the running
// platform. Errors are returned to the caller — the frontend decides
// whether to surface them or stay quiet.
func Check(ctx context.Context) (Status, error) {
s := Status{Current: version.Version}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, VersionURL, nil)
if err != nil {
return s, err
}
// GitHub's raw host doesn't require a UA but rejects empty Accept
// occasionally; set both defensively.
req.Header.Set("User-Agent", "thefeed-client/"+version.Version)
req.Header.Set("Accept", "text/plain")
resp, err := httpClient.Do(req)
if err != nil {
return s, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return s, fmt.Errorf("VERSION fetch: %s", resp.Status)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024))
if err != nil {
return s, err
}
s.Latest = strings.TrimSpace(string(body))
if s.Latest == "" {
return s, fmt.Errorf("VERSION file empty")
}
s.HasUpdate = IsNewer(s.Latest, s.Current)
s.DownloadURL = AssetURL(s.Latest)
return s, nil
}
// AssetURL builds the download URL for the running platform at the
// requested version. Falls back to a runtime-derived template if
// AssetTemplate wasn't injected at build time (e.g., `go run`).
func AssetURL(latest string) string {
tmpl := version.AssetTemplate
if isAndroidAPK() {
// APK wrapper takes priority over the bare client binary —
// users who installed the APK should update the APK.
tmpl = androidAPKTemplate()
}
if tmpl == "" {
tmpl = defaultTemplate()
}
if tmpl == "" {
return ""
}
name := strings.ReplaceAll(tmpl, "{V}", strings.TrimSpace(latest))
return BaseURL + "/" + name
}
// IsNewer compares semver-ish version strings, tolerating the "v" prefix
// and numeric pre-release suffixes. Returns false if either side is "dev".
func IsNewer(latest, current string) bool {
a := strings.TrimPrefix(strings.TrimSpace(latest), "v")
b := strings.TrimPrefix(strings.TrimSpace(current), "v")
if a == "" || b == "" {
return false
}
if b == "dev" {
// `go run` / unreleased build — never nag.
return false
}
if a == b {
return false
}
as := strings.Split(stripPre(a), ".")
bs := strings.Split(stripPre(b), ".")
n := len(as)
if len(bs) > n {
n = len(bs)
}
for i := 0; i < n; i++ {
ai, bi := 0, 0
if i < len(as) {
ai, _ = strconv.Atoi(as[i])
}
if i < len(bs) {
bi, _ = strconv.Atoi(bs[i])
}
if ai > bi {
return true
}
if ai < bi {
return false
}
}
return false
}
func stripPre(v string) string {
if i := strings.IndexAny(v, "-+"); i >= 0 {
return v[:i]
}
return v
}
// isAndroidAPK returns true when this binary is running inside the
// Android APK wrapper rather than as a standalone Termux/CLI client.
// Two signals are checked:
// - THEFEED_ANDROID_APK=1 set by ThefeedService.kt before exec.
// - The executable path lives under com.thefeed.android's
// nativeLibraryDir, which always contains "com.thefeed.android".
func isAndroidAPK() bool {
if runtime.GOOS != "android" {
return false
}
if os.Getenv("THEFEED_ANDROID_APK") == "1" {
return true
}
if exe, err := os.Executable(); err == nil {
if strings.Contains(exe, "com.thefeed.android") {
return true
}
}
return false
}
// androidAPKTemplate returns the asset name for the user-facing APK
// (not the raw client binary) at version "{V}".
func androidAPKTemplate() string {
abi := "arm64-v8a"
if runtime.GOARCH == "arm" {
abi = "armeabi-v7a"
}
return "thefeed-android-{V}-" + abi + ".apk"
}
// defaultTemplate is the fallback used when AssetTemplate wasn't
// injected by ldflags. Mirrors the matrix in .github/workflows/build.yml.
func defaultTemplate() string {
switch runtime.GOOS {
case "android":
return "thefeed-client-android-" + runtime.GOARCH
case "windows":
return "thefeed-client-{V}-windows-" + runtime.GOARCH + ".exe"
default:
return "thefeed-client-{V}-" + runtime.GOOS + "-" + runtime.GOARCH
}
}