mirror of
https://github.com/sartoopjj/thefeed.git
synced 2026-05-18 06:34:36 +03:00
191 lines
5.4 KiB
Go
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
|
|
}
|
|
}
|