From 6c1bb9f58f42ae661072a5cc084e265e1d9ba22c Mon Sep 17 00:00:00 2001 From: Sarto Date: Sat, 2 May 2026 17:22:28 +0330 Subject: [PATCH] feat: add GitHub update check and APK handling for in-app updates --- .github/workflows/build.yml | 11 +- Makefile | 26 ++- .../com/thefeed/android/ThefeedService.kt | 3 + internal/update/update.go | 190 ++++++++++++++++++ internal/update/update_test.go | 79 ++++++++ internal/version/version.go | 14 ++ internal/web/static/index.html | 88 +++++++- internal/web/web.go | 21 ++ 8 files changed, 420 insertions(+), 12 deletions(-) create mode 100644 internal/update/update.go create mode 100644 internal/update/update_test.go diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bc57ea3..c696471 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -80,10 +80,19 @@ jobs: VERSION=${GITHUB_REF_NAME:-dev} COMMIT=$(git rev-parse --short HEAD) DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) - LDFLAGS="-s -w -X github.com/sartoopjj/thefeed/internal/version.Version=${VERSION} -X github.com/sartoopjj/thefeed/internal/version.Commit=${COMMIT} -X github.com/sartoopjj/thefeed/internal/version.Date=${DATE}" ext="" BUILD_MODE="" if [ "${{ matrix.goos }}" = "windows" ]; then ext=".exe"; fi + # AssetTemplate matches the published filename in the + # thefeed-files repo so the in-app update prompt links straight + # to the right binary. {V} is replaced at runtime with the + # version string read from the public VERSION file. + if [ "${{ matrix.goos }}" = "android" ]; then + ASSET_TEMPLATE="thefeed-client-android-${{ matrix.goarch }}" + else + ASSET_TEMPLATE="thefeed-client-{V}-${{ matrix.goos }}-${{ matrix.goarch }}${ext}" + fi + LDFLAGS="-s -w -X github.com/sartoopjj/thefeed/internal/version.Version=${VERSION} -X github.com/sartoopjj/thefeed/internal/version.Commit=${COMMIT} -X github.com/sartoopjj/thefeed/internal/version.Date=${DATE} -X github.com/sartoopjj/thefeed/internal/version.AssetTemplate=${ASSET_TEMPLATE}" # Modern Android requires PIE for executables launched via exec(), # and several heuristic AV engines (Kaspersky Boogr.gsh, # several VT vendors) flag non-PIE bundled binaries as suspicious. diff --git a/Makefile b/Makefile index b658c63..fbd382e 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,14 @@ LDFLAGS = -s -w \ GOFLAGS = -trimpath -ldflags="$(LDFLAGS)" export CGO_ENABLED = 0 +# CLIENT_GOFLAGS appends the platform-specific AssetTemplate so the +# in-app GitHub update check (internal/update) can point users at the +# right published binary. {V} is replaced at runtime with the version +# string read from the public VERSION file. Pass the asset filename as +# the first argument. +# $(call CLIENT_GOFLAGS,thefeed-client-{V}-linux-amd64) +CLIENT_GOFLAGS = -trimpath -ldflags="$(LDFLAGS) -X github.com/sartoopjj/thefeed/internal/version.AssetTemplate=$(1)" + all: test build build: build-server build-client @@ -49,45 +57,45 @@ build-all: build-linux-amd64 build-linux-arm64 build-darwin-amd64 build-darwin-a build-linux-amd64: @mkdir -p $(BUILD_DIR) GOOS=linux GOARCH=amd64 go build $(GOFLAGS) -o $(BUILD_DIR)/$(BINARY_SERVER)-linux-amd64 ./cmd/server - GOOS=linux GOARCH=amd64 go build $(GOFLAGS) -o $(BUILD_DIR)/$(BINARY_CLIENT)-linux-amd64 ./cmd/client + GOOS=linux GOARCH=amd64 go build $(call CLIENT_GOFLAGS,thefeed-client-{V}-linux-amd64) -o $(BUILD_DIR)/$(BINARY_CLIENT)-linux-amd64 ./cmd/client build-linux-arm64: @mkdir -p $(BUILD_DIR) GOOS=linux GOARCH=arm64 go build $(GOFLAGS) -o $(BUILD_DIR)/$(BINARY_SERVER)-linux-arm64 ./cmd/server - GOOS=linux GOARCH=arm64 go build $(GOFLAGS) -o $(BUILD_DIR)/$(BINARY_CLIENT)-linux-arm64 ./cmd/client + GOOS=linux GOARCH=arm64 go build $(call CLIENT_GOFLAGS,thefeed-client-{V}-linux-arm64) -o $(BUILD_DIR)/$(BINARY_CLIENT)-linux-arm64 ./cmd/client build-darwin-amd64: @mkdir -p $(BUILD_DIR) GOOS=darwin GOARCH=amd64 go build $(GOFLAGS) -o $(BUILD_DIR)/$(BINARY_SERVER)-darwin-amd64 ./cmd/server - GOOS=darwin GOARCH=amd64 go build $(GOFLAGS) -o $(BUILD_DIR)/$(BINARY_CLIENT)-darwin-amd64 ./cmd/client + GOOS=darwin GOARCH=amd64 go build $(call CLIENT_GOFLAGS,thefeed-client-{V}-darwin-amd64) -o $(BUILD_DIR)/$(BINARY_CLIENT)-darwin-amd64 ./cmd/client build-darwin-arm64: @mkdir -p $(BUILD_DIR) GOOS=darwin GOARCH=arm64 go build $(GOFLAGS) -o $(BUILD_DIR)/$(BINARY_SERVER)-darwin-arm64 ./cmd/server - GOOS=darwin GOARCH=arm64 go build $(GOFLAGS) -o $(BUILD_DIR)/$(BINARY_CLIENT)-darwin-arm64 ./cmd/client + GOOS=darwin GOARCH=arm64 go build $(call CLIENT_GOFLAGS,thefeed-client-{V}-darwin-arm64) -o $(BUILD_DIR)/$(BINARY_CLIENT)-darwin-arm64 ./cmd/client build-freebsd-amd64: @mkdir -p $(BUILD_DIR) GOOS=freebsd GOARCH=amd64 go build $(GOFLAGS) -o $(BUILD_DIR)/$(BINARY_SERVER)-freebsd-amd64 ./cmd/server - GOOS=freebsd GOARCH=amd64 go build $(GOFLAGS) -o $(BUILD_DIR)/$(BINARY_CLIENT)-freebsd-amd64 ./cmd/client + GOOS=freebsd GOARCH=amd64 go build $(call CLIENT_GOFLAGS,thefeed-client-{V}-freebsd-amd64) -o $(BUILD_DIR)/$(BINARY_CLIENT)-freebsd-amd64 ./cmd/client build-freebsd-arm64: @mkdir -p $(BUILD_DIR) GOOS=freebsd GOARCH=arm64 go build $(GOFLAGS) -o $(BUILD_DIR)/$(BINARY_SERVER)-freebsd-arm64 ./cmd/server - GOOS=freebsd GOARCH=arm64 go build $(GOFLAGS) -o $(BUILD_DIR)/$(BINARY_CLIENT)-freebsd-arm64 ./cmd/client + GOOS=freebsd GOARCH=arm64 go build $(call CLIENT_GOFLAGS,thefeed-client-{V}-freebsd-arm64) -o $(BUILD_DIR)/$(BINARY_CLIENT)-freebsd-arm64 ./cmd/client build-windows-amd64: @mkdir -p $(BUILD_DIR) GOOS=windows GOARCH=amd64 go build $(GOFLAGS) -o $(BUILD_DIR)/$(BINARY_SERVER)-windows-amd64.exe ./cmd/server - GOOS=windows GOARCH=amd64 go build $(GOFLAGS) -o $(BUILD_DIR)/$(BINARY_CLIENT)-windows-amd64.exe ./cmd/client + GOOS=windows GOARCH=amd64 go build $(call CLIENT_GOFLAGS,thefeed-client-{V}-windows-amd64.exe) -o $(BUILD_DIR)/$(BINARY_CLIENT)-windows-amd64.exe ./cmd/client build-android-arm64: @mkdir -p $(BUILD_DIR) - GOOS=android GOARCH=arm64 go build $(GOFLAGS) -o $(BUILD_DIR)/$(BINARY_CLIENT)-android-arm64 ./cmd/client + GOOS=android GOARCH=arm64 go build $(call CLIENT_GOFLAGS,thefeed-client-android-arm64) -o $(BUILD_DIR)/$(BINARY_CLIENT)-android-arm64 ./cmd/client build-android-arm: @mkdir -p $(BUILD_DIR) - GOOS=linux GOARCH=arm GOARM=7 go build $(GOFLAGS) -o $(BUILD_DIR)/$(BINARY_CLIENT)-android-arm ./cmd/client + GOOS=linux GOARCH=arm GOARM=7 go build $(call CLIENT_GOFLAGS,thefeed-client-android-arm) -o $(BUILD_DIR)/$(BINARY_CLIENT)-android-arm ./cmd/client # UPX compression (requires upx in PATH) — only for Linux/Windows binaries upx: diff --git a/android/app/src/main/java/com/thefeed/android/ThefeedService.kt b/android/app/src/main/java/com/thefeed/android/ThefeedService.kt index 5379a5b..ad2b665 100644 --- a/android/app/src/main/java/com/thefeed/android/ThefeedService.kt +++ b/android/app/src/main/java/com/thefeed/android/ThefeedService.kt @@ -81,6 +81,9 @@ class ThefeedService : Service() { val env = mutableMapOf() env["HOME"] = filesDir.absolutePath env["TMPDIR"] = cacheDir.absolutePath + // Tells internal/update to point the user at the APK on + // GitHub instead of the bare client binary. + env["THEFEED_ANDROID_APK"] = "1" val pb = ProcessBuilder( bin.absolutePath, diff --git a/internal/update/update.go b/internal/update/update.go new file mode 100644 index 0000000..9e84774 --- /dev/null +++ b/internal/update/update.go @@ -0,0 +1,190 @@ +// 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 + } +} diff --git a/internal/update/update_test.go b/internal/update/update_test.go new file mode 100644 index 0000000..79e38ad --- /dev/null +++ b/internal/update/update_test.go @@ -0,0 +1,79 @@ +package update + +import ( + "runtime" + "strings" + "testing" + + "github.com/sartoopjj/thefeed/internal/version" +) + +func TestIsNewer(t *testing.T) { + cases := []struct { + latest, current string + want bool + }{ + {"v0.13.5", "v0.13.4", true}, + {"v0.13.5", "0.13.4", true}, + {"0.13.5", "v0.13.4", true}, + {"v0.13.5", "v0.13.5", false}, + {"v0.13.4", "v0.13.5", false}, + {"v1.0.0", "v0.99.99", true}, + {"v0.13.5", "dev", false}, + {"", "v0.13.5", false}, + {"v0.13.5", "", false}, + {"v0.13.5-rc1", "v0.13.4", true}, + {"v0.13.5", "v0.13.5-rc1", false}, // numeric parts equal → not newer + } + for _, c := range cases { + if got := IsNewer(c.latest, c.current); got != c.want { + t.Errorf("IsNewer(%q, %q) = %v, want %v", c.latest, c.current, got, c.want) + } + } +} + +func TestAssetURLFromTemplate(t *testing.T) { + old := version.AssetTemplate + defer func() { version.AssetTemplate = old }() + + version.AssetTemplate = "thefeed-client-{V}-linux-amd64" + url := AssetURL("v0.13.5") + want := BaseURL + "/thefeed-client-v0.13.5-linux-amd64" + if url != want { + t.Errorf("got %q, want %q", url, want) + } + + version.AssetTemplate = "thefeed-client-{V}-windows-amd64.exe" + url = AssetURL("v0.14.0") + want = BaseURL + "/thefeed-client-v0.14.0-windows-amd64.exe" + if url != want { + t.Errorf("got %q, want %q", url, want) + } + + // Unversioned template (Android client binary) — {V} not present, + // substitution should be a no-op. + version.AssetTemplate = "thefeed-client-android-arm64" + url = AssetURL("v0.13.5") + want = BaseURL + "/thefeed-client-android-arm64" + if url != want { + t.Errorf("got %q, want %q", url, want) + } +} + +func TestAssetURLFallback(t *testing.T) { + old := version.AssetTemplate + defer func() { version.AssetTemplate = old }() + version.AssetTemplate = "" + + url := AssetURL("v0.13.5") + if url == "" { + t.Fatal("expected non-empty URL even without AssetTemplate") + } + if !strings.HasPrefix(url, BaseURL+"/") { + t.Errorf("URL %q missing base prefix", url) + } + // Should at minimum mention the running OS. + if !strings.Contains(url, runtime.GOOS) && runtime.GOOS != "android" { + t.Errorf("URL %q should mention %q", url, runtime.GOOS) + } +} diff --git a/internal/version/version.go b/internal/version/version.go index 1079c8b..18e6adf 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -5,4 +5,18 @@ var ( Version = "dev" Commit = "unknown" Date = "unknown" + + // AssetTemplate is the GitHub release asset filename pattern for the + // running platform. The literal "{V}" is replaced with the new version + // string (with the "v" prefix, e.g., "v0.13.5") at update-check time. + // + // Examples baked in by the build workflow: + // thefeed-client-{V}-linux-amd64 + // thefeed-client-{V}-windows-amd64.exe + // thefeed-client-android-arm64 (no version — Android client) + // + // The Android-APK case is detected at runtime in internal/update and + // overrides this with thefeed-android-{V}-{abi}.apk because the same + // client binary ships both inside the APK and as a Termux download. + AssetTemplate = "" ) diff --git a/internal/web/static/index.html b/internal/web/static/index.html index ed4f687..be11ddb 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -2219,9 +2219,11 @@ Latest Version - -
+
+ data-i18n="check_now" style="flex:1">Check Now +
' + + ' ' + + '
'; + document.body.appendChild(overlay); + document.getElementById('updateLater').onclick = function () { + if (overlay.parentNode) overlay.parentNode.removeChild(overlay); + }; + document.getElementById('updateDownload').onclick = function () { + try { + var a = document.createElement('a'); + a.href = url; + a.target = '_blank'; + a.rel = 'noopener noreferrer'; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + a.remove(); + } catch (e) { } + if (overlay.parentNode) overlay.parentNode.removeChild(overlay); + }; + } + async function checkLatestVersion() { var btn = document.getElementById('checkVersionBtn'); var prevText = btn ? btn.textContent : ''; diff --git a/internal/web/web.go b/internal/web/web.go index 60ab9af..6ea5a2e 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -24,6 +24,7 @@ import ( "github.com/sartoopjj/thefeed/internal/client" "github.com/sartoopjj/thefeed/internal/protocol" + "github.com/sartoopjj/thefeed/internal/update" "github.com/sartoopjj/thefeed/internal/version" ) @@ -264,6 +265,7 @@ func (s *Server) Run() error { mux.HandleFunc("/api/auto-update/toggle", s.handleAutoUpdateToggle) mux.HandleFunc("/api/settings", s.handleSettings) mux.HandleFunc("/api/version-check", s.handleVersionCheck) + mux.HandleFunc("/api/update/github", s.handleGitHubUpdateCheck) mux.HandleFunc("/api/cache/clear", s.handleClearCache) mux.HandleFunc("/api/bg-image", s.handleBgImage) mux.HandleFunc("/api/resolvers/apply-saved", s.handleApplySavedResolvers) @@ -2581,6 +2583,25 @@ func (s *Server) handleVersionCheck(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{"ok": true, "latestVersion": v}) } +// handleGitHubUpdateCheck queries the public thefeed-files repo for the +// latest published client version and returns a download URL tailored +// to this binary's platform. Independent of the DNS-protocol version +// check above — works without a configured profile. +func (s *Server) handleGitHubUpdateCheck(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodPost { + http.Error(w, "method not allowed", 405) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 35*time.Second) + defer cancel() + st, err := update.Check(ctx) + if err != nil { + http.Error(w, fmt.Sprintf("update check failed: %v", err), 502) + return + } + writeJSON(w, st) +} + // runMediaCacheSweep evicts expired media-cache entries every hour for the // lifetime of the process. func (s *Server) runMediaCacheSweep() {