mirror of
https://github.com/sartoopjj/thefeed.git
synced 2026-05-18 04:54:34 +03:00
feat: add GitHub update check and APK handling for in-app updates
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 = ""
|
||||
)
|
||||
|
||||
@@ -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 : '';
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user