feat: add GitHub update check and APK handling for in-app updates

This commit is contained in:
Sarto
2026-05-02 17:22:28 +03:30
parent cee80685d7
commit 6c1bb9f58f
8 changed files with 420 additions and 12 deletions
+10 -1
View File
@@ -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.
+17 -9
View File
@@ -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:
@@ -81,6 +81,9 @@ class ThefeedService : Service() {
val env = mutableMapOf<String, String>()
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,
+190
View File
@@ -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
}
}
+79
View File
@@ -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)
}
}
+14
View File
@@ -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 = ""
)
+86 -2
View File
@@ -2219,9 +2219,11 @@
<span data-i18n="latest_version">Latest Version</span>
<span id="latestVersionEl">-</span>
</div>
<div class="settings-info-row">
<div class="settings-info-row" style="display:flex;gap:6px">
<button class="btn btn-outline btn-sm" id="checkVersionBtn" onclick="checkLatestVersion()"
data-i18n="check_now" style="width:100%">Check Now</button>
data-i18n="check_now" style="flex:1">Check Now</button>
<button class="btn btn-outline btn-sm" id="checkGitHubBtn" onclick="checkGitHubUpdate(true)"
data-i18n="check_github" style="flex:1">Check on GitHub</button>
</div>
<div class="settings-info-row">
<button class="btn btn-flat btn-sm" onclick="clearCache()"
@@ -2585,9 +2587,14 @@
latest_version: 'آخرین نسخه قابل دانلود',
check_latest_version: 'بررسی نسخه جدید',
check_now: 'بررسی',
check_github: 'بررسی در گیتهاب',
checking_version: 'در حال بررسی...',
version_up_to_date: 'نسخه شما به‌روز است: {v}',
version_check_failed: 'بررسی نسخه ناموفق بود',
update_check_failed: 'بررسی به‌روزرسانی ناموفق بود',
update_download_hint: 'برای دانلود نسخه جدید روی دکمه زیر کلیک کنید:',
update_download_btn: 'دانلود',
update_later_btn: 'بعداً',
channel_mgmt_note: 'قابلیت مدیریت کانال نیاز به فعال سازی سمت سرور دارد. اگر توسط ادمین غیرفعال شده باشد، افزودن/حذف کار نمی\u200cکند.',
channel_mgmt_inactive: 'برای مدیریت کانال\u200cها، ابتدا این پروفایل را فعال کنید.',
channel_placeholder: 'نام کاربری کانال',
@@ -2758,9 +2765,14 @@
latest_version: 'Latest Version',
check_latest_version: 'Check for Updates',
check_now: 'Check Now',
check_github: 'Check on GitHub',
checking_version: 'Checking...',
version_up_to_date: 'You are up to date: {v}',
version_check_failed: 'Version check failed',
update_check_failed: 'Update check failed',
update_download_hint: 'Click the button below to download the new version:',
update_download_btn: 'Download',
update_later_btn: 'Later',
channel_mgmt_note: 'Channel management requires server-side support. If disabled by the server admin, adding/removing channels will not work.',
channel_mgmt_inactive: 'Switch to this profile first to manage its channels.',
channel_placeholder: 'channel_username',
@@ -2998,6 +3010,10 @@
loadBgImage();
connectSSE();
refreshResolversBadge();
// Quietly ask GitHub for the latest published client version. Runs in
// the background so a slow github.com response can't delay startup —
// if there's an update, the dialog shows up a few seconds later.
checkGitHubUpdate(false).catch(function () { });
try {
var r = await fetch('/api/status'); var st = await r.json();
await loadProfiles();
@@ -3256,6 +3272,74 @@
} catch (e) { }
}
// checkGitHubUpdate hits /api/update/github (which fetches the VERSION
// file from the public thefeed-files repo) and prompts the user with
// a download link tailored to their platform. `manual=true` shows a
// toast on "no update", `manual=false` stays silent.
async function checkGitHubUpdate(manual) {
try {
var r = await fetch('/api/update/github');
if (!r.ok) {
if (manual) showToast(t('update_check_failed') || 'Update check failed');
return;
}
var data = await r.json();
if (!data || !data.latest) return;
latestVersion = data.latest;
renderLatestVersion();
if (data.hasUpdate && data.downloadURL) {
if (!manual) {
// Don't nag the same user about the same version twice.
var seenKey = 'thefeed_seen_gh_update_' + normalizeVersion(data.latest);
if (localStorage.getItem(seenKey) === '1') return;
localStorage.setItem(seenKey, '1');
}
showUpdateDialog(data.latest, data.downloadURL);
} else if (manual) {
showToast((t('version_up_to_date') || 'Up to date: {v}').replace('{v}', data.latest));
}
} catch (e) {
if (manual) showToast(e.message || t('update_check_failed') || 'Update check failed');
}
}
function showUpdateDialog(newVersion, url) {
// Re-use the existing modal styling. Two buttons: download (opens
// the binary URL in a new tab / hands off to system app on Android)
// and later (just dismisses).
var msg = (t('update_available') || 'New version available: {v}').replace('{v}', newVersion);
var hint = t('update_download_hint') || 'Download the new version below.';
var dl = t('update_download_btn') || 'Download';
var later = t('update_later_btn') || 'Later';
var overlay = document.createElement('div');
overlay.className = 'modal-overlay active';
overlay.innerHTML = '<div class="modal" style="max-width:380px">'
+ '<h2 style="margin-top:0">' + esc(msg) + '</h2>'
+ '<p style="font-size:13px;color:var(--text-dim);margin-bottom:12px;line-height:1.6">' + esc(hint) + '</p>'
+ '<p style="font-size:11px;color:var(--text-dim);margin-bottom:16px;word-break:break-all"><code>' + esc(url) + '</code></p>'
+ '<div class="modal-actions">'
+ ' <button class="btn btn-flat" id="updateLater">' + esc(later) + '</button>'
+ ' <button class="btn btn-primary" id="updateDownload">' + esc(dl) + '</button>'
+ '</div></div>';
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 : '';
+21
View File
@@ -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() {